diff --git a/core/COMPLIANCE.md b/core/COMPLIANCE.md
index d23a79c..55220b4 100644
--- a/core/COMPLIANCE.md
+++ b/core/COMPLIANCE.md
@@ -8,28 +8,28 @@ This document details the feature support and compliance status for each exchang
## Functions Status
-| Category | Function | Polymarket | Kalshi | Limitless | Probable | Baozi | Myriad | Opinion |
-| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
-| **Market Data** | `fetchMarkets` | Y | Y | Y | Y | Y | Y | Y |
-| | `fetchEvents` | Y | Y | Y | Y | Y | Y | Y |
-| | `fetchMarket` | Y | Y | Y | Y | Y | Y | Y |
-| | `fetchEvent` | Y | Y | Y | Y | Y | Y | Y |
-| **Public Data** | `fetchOHLCV` | Y | Y | Y | Y | Y | Y | Y |
-| | `fetchOrderBook` | Y | Y | Y | Y | Y | Y | Y |
-| | `fetchTrades` | Y | Y | Y | Y | Y | Y | - |
-| **Private Data** | `fetchBalance` | Y | Y | Y | Y | Y | Y | - |
-| | `fetchPositions` | Y | Y | Y | Y | Y | Y | Y |
-| | `fetchMyTrades` | Y | Y | Y | Y | - | Y | Y |
-| **Trading** | `createOrder` | Y | Y | Y | Y | Y | Y | Y |
-| | `cancelOrder` | Y | Y | Y | Y | Y | - | Y |
-| | `fetchOrder` | Y | Y | Y | Y | Y | - | Y |
-| | `fetchOpenOrders` | Y | Y | Y | Y | Y | Y | Y |
-| | `fetchClosedOrders` | - | Y | Y | - | - | - | Y |
-| | `fetchAllOrders` | - | Y | Y | - | - | - | Y |
-| **Calculations** | `getExecutionPrice` | Y | Y | Y | Y | Y | Y | Y |
-| | `getExecutionPriceDetailed` | Y | Y | Y | Y | Y | Y | Y |
-| **Real-time** | `watchOrderBook` | Y | Y | Y | Y | Y | Y | Y |
-| | `watchTrades` | Y | Y | Y | - | - | Y | Y |
+| Category | Function | Polymarket | Kalshi | Limitless | Probable | Baozi | Myriad | Opinion | Metaculus |
+| :--- | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: |
+| **Market Data** | `fetchMarkets` | Y | Y | Y | Y | Y | Y | Y | Y |
+| | `fetchEvents` | Y | Y | Y | Y | Y | Y | Y | Y |
+| | `fetchMarket` | Y | Y | Y | Y | Y | Y | Y | Y |
+| | `fetchEvent` | Y | Y | Y | Y | Y | Y | Y | Y |
+| **Public Data** | `fetchOHLCV` | Y | Y | Y | Y | Y | Y | Y | - |
+| | `fetchOrderBook` | Y | Y | Y | Y | Y | Y | Y | - |
+| | `fetchTrades` | Y | Y | Y | Y | Y | Y | - | - |
+| **Private Data** | `fetchBalance` | Y | Y | Y | Y | Y | Y | - | - |
+| | `fetchPositions` | Y | Y | Y | Y | Y | Y | Y | - |
+| | `fetchMyTrades` | Y | Y | Y | Y | - | Y | Y | - |
+| **Trading** | `createOrder` | Y | Y | Y | Y | Y | Y | Y | - |
+| | `cancelOrder` | Y | Y | Y | Y | Y | - | Y | - |
+| | `fetchOrder` | Y | Y | Y | Y | Y | - | Y | - |
+| | `fetchOpenOrders` | Y | Y | Y | Y | Y | Y | Y | - |
+| | `fetchClosedOrders` | - | Y | Y | - | - | - | Y | - |
+| | `fetchAllOrders` | - | Y | Y | - | - | - | Y | - |
+| **Calculations** | `getExecutionPrice` | Y | Y | Y | Y | Y | Y | Y | - |
+| | `getExecutionPriceDetailed` | Y | Y | Y | Y | Y | Y | Y | - |
+| **Real-time** | `watchOrderBook` | Y | Y | Y | Y | Y | Y | Y | - |
+| | `watchTrades` | Y | Y | Y | - | - | Y | Y | - |
## Legend
- **Y** - Supported
@@ -50,4 +50,6 @@ LIMITLESS_PRIVATE_KEY=0x...
# Myriad
MYRIAD_API_KEY=...
MYRIAD_WALLET_ADDRESS=0x...
+# Metaculus (required for API access — unauthenticated requests return 403)
+METACULUS_API_TOKEN=...
```
diff --git a/core/scripts/generate-compliance.js b/core/scripts/generate-compliance.js
index ea30994..6d11a72 100644
--- a/core/scripts/generate-compliance.js
+++ b/core/scripts/generate-compliance.js
@@ -23,7 +23,7 @@ const METHOD_CATEGORIES = [
];
// Exchange display order (skip kalshi-demo since it inherits Kalshi fully)
-const EXCHANGE_ORDER = ['polymarket', 'kalshi', 'limitless', 'probable', 'baozi', 'myriad', 'opinion'];
+const EXCHANGE_ORDER = ['polymarket', 'kalshi', 'limitless', 'probable', 'baozi', 'myriad', 'opinion', 'metaculus'];
function toDisplayName(slug) {
return slug.split('-').map(p => p.charAt(0).toUpperCase() + p.slice(1)).join('');
@@ -187,6 +187,8 @@ LIMITLESS_PRIVATE_KEY=0x...
# Myriad
MYRIAD_API_KEY=...
MYRIAD_WALLET_ADDRESS=0x...
+# Metaculus (required for API access — unauthenticated requests return 403)
+METACULUS_API_TOKEN=...
\`\`\`
`;
diff --git a/core/scripts/generate-python-exchanges.js b/core/scripts/generate-python-exchanges.js
index 83bff69..607f0ea 100644
--- a/core/scripts/generate-python-exchanges.js
+++ b/core/scripts/generate-python-exchanges.js
@@ -84,6 +84,7 @@ function build(name, block) {
name,
creds: {
apiKey: /credentials\?\.apiKey/.test(block),
+ apiToken: /credentials\?\.apiToken/.test(block),
apiSecret: /credentials\?\.apiSecret/.test(block),
passphrase: /credentials\?\.passphrase/.test(block),
privateKey: /credentials\?\.privateKey/.test(block),
@@ -110,6 +111,10 @@ function generateClass(exchange) {
constructorParams.push('api_key: Optional[str] = None');
superArgs.push('api_key=api_key');
}
+ if (creds.apiToken) {
+ constructorParams.push('api_token: Optional[str] = None');
+ superArgs.push('api_token=api_token');
+ }
if (creds.apiSecret) {
constructorParams.push('api_secret: Optional[str] = None');
extraAttrs.push('self.api_secret = api_secret');
@@ -142,6 +147,7 @@ function generateClass(exchange) {
const docLines = [];
if (creds.apiKey) docLines.push(' api_key: API key for authentication (optional)');
+ if (creds.apiToken) docLines.push(' api_token: API token for authentication (optional; required for Metaculus API access)');
if (creds.apiSecret) docLines.push(' api_secret: API secret for authentication (optional)');
if (creds.passphrase) docLines.push(' passphrase: Passphrase for authentication (optional)');
if (creds.privateKey) {
diff --git a/core/specs/metaculus/Metaculus.yaml b/core/specs/metaculus/Metaculus.yaml
new file mode 100644
index 0000000..a1ce9d6
--- /dev/null
+++ b/core/specs/metaculus/Metaculus.yaml
@@ -0,0 +1,2069 @@
+openapi: 3.0.0
+info:
+ version: 2.0.0
+ title: Metaculus API
+ description: |
+ Welcome to the official Metaculus API!
+
+ If you have questions, ideas, or feedback, please contact our team at
+ api-requests@metaculus.com. We are excited to keep building upon this
+ initial version of the API, and we’d like to keep making it more useful
+ to you. Our aim is to support the forecasting community – we’re listening!
+
+ Get Started in 15 Seconds
+
+
+
Most of the API is (hopefully) self-explanatory. You’ll find all the documentation below.
+
If you’re testing the waters or doing a one-off analysis, you can dive right in!
+
If you’re building an application that connects to the Metaculus API, you’ll need to authenticate.
+
+
+ How to Authenticate
+
+ You can see your current API token (or generate one) at your settings page, under the header "API Access".
+
+ You can then add the token to your requests using the Authorization HTTP header. The token should be prefixed by the string literal "Token", with whitespace separating the two strings.
+
+ Example:
+
+
+
+ If a question has a closed lower bound
+ (`question["open_lower_bound"] == False`), the first value of the CDF
+ must be 0.0. Otherwise it must be at least 0.001 - meaning at least
+ 0.1% of the probability mass must be assigned below the lower bound.
+
+ If a question has a closed upper bound
+ (`question["open_upper_bound"] == False`), the last value of the CDF
+ must be 1.0, otherwise no more than 0.999 - meaning at least
+ 0.1% of the probability mass must be assigned above the upper bound.
+
+ At least 1% of the probability mass must be assigned uniformly within
+ bounds, which means that the CDF must be strictly increasing by at
+ least 0.01/200 = 0.0005 per step. No two adjacent values of the CDF
+ can differ by more than 0.59, which is the largest number obtainable
+ via the sliders.
+ type: array
+ items:
+ type: number
+ format: float
+ distribution_input:
+ description: Optional. Slider values used for populating the sliders on the frontend at page load.
+ type: object
+ required:
+ - type
+ - components
+ properties:
+ type:
+ type: string
+ enum: [ slider, quantile ]
+ example: slider
+ forecast:
+ type: array
+ items:
+ type: object
+ properties:
+ center:
+ type: number
+ format: float
+ left:
+ type: number
+ format: float
+ right:
+ type: number
+ format: float
+ weight:
+ type: number
+ format: float
+
+ MultipleChoiceForecast:
+ type: object
+ required:
+ - question
+ - probability_yes_per_category
+ properties:
+ question:
+ type: integer
+ probability_yes_per_category:
+ type: object
+ additionalProperties:
+ type: number
+ format: float
+ description: Probability distribution for multiple categories. Sum of values must equal 1.0.
+
+ ConditionalForecast:
+ type: array
+ description: Forecast for conditional questions (if Yes/No scenarios)
+ items:
+ type: object
+ required:
+ - question
+ - probability_yes
+ properties:
+ question:
+ type: integer
+ probability_yes:
+ type: number
+ format: float
+
+ GroupForecast:
+ type: array
+ description: Group forecast of Binary, Numeric or Date subquestions
+ items:
+ oneOf:
+ - $ref: '#/components/schemas/BinaryForecast'
+ - $ref: '#/components/schemas/ContinuousForecast'
+
+ Withdrawal:
+ type: object
+ required:
+ - question
+ properties:
+ question:
+ type: integer
+
+ Comment:
+ type: object
+ properties:
+ id:
+ type: integer
+ description: Unique identifier for the comment.
+ author:
+ type: object
+ properties:
+ id:
+ type: integer
+ description: Author's user ID.
+ username:
+ type: string
+ description: Author's username.
+ is_bot:
+ type: boolean
+ description: If the author is a bot.
+ is_staff:
+ type: boolean
+ description: If the author is a staff member.
+ parent_id:
+ type: integer
+ nullable: true
+ description: ID of the comment being replied to (if applicable).
+ root_id:
+ type: integer
+ nullable: true
+ description: ID of the root comment in the thread.
+ created_at:
+ type: string
+ format: date-time
+ description: Timestamp when the comment was created.
+ text:
+ type: string
+ description: The content of the comment.
+ on_post:
+ type: integer
+ description: ID of the post the comment belongs to.
+ included_forecast:
+ type: boolean
+ description: If the user's last forecast is included.
+ is_private:
+ type: boolean
+ description: If the comment is private.
+ vote_score:
+ type: integer
+ description: Total vote score for the comment.
+ changed_my_mind:
+ type: object
+ properties:
+ count:
+ type: integer
+ description: Number of users who changed their mind based on this comment.
+ for_this_user:
+ type: boolean
+ description: If the current user changed their mind based on this comment.
+ mentioned_users:
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: integer
+ description: ID of the mentioned user.
+ username:
+ type: string
+ description: Username of the mentioned user.
+ user_vote:
+ type: integer
+ description: Current user's vote on this comment (-1, 0, 1).
+
+ QuestionDataCSV:
+ type: object
+ properties:
+ Question ID:
+ type: integer
+ description: The ID of the question.
+ example: 3
+ Question Title:
+ type: string
+ description: The title of the (sub)question.
+ Post ID:
+ type: integer
+ description: The ID of the post containing the question.
+ Post Curation Status:
+ type: string
+ enum: [ draft, pending, rejected, approved ]
+ example: pending
+ Post Published Time:
+ type: string
+ format: date-time
+ description: The time the post was published.
+ Default Project:
+ type: string
+ description: The default project for the Post.
+ Default Project ID:
+ type: integer
+ description: The ID of the default project for the Post.
+ Label:
+ type: string
+ description: For sub questions only. The label of the question.
+ Question Type:
+ type: string
+ description: The type of the question.
+ enum: [ binary, multiple_choice, numeric, date ]
+ example: binary
+ MC Options:
+ type: array
+ items:
+ type: string
+ description: For multiple choice questions only. The options for the question.
+ Scaling:
+ type: object
+ description: For continuous questions only. The scaling of the question.
+ properties:
+ Range Min:
+ type: number
+ format: float
+ description: The minimum value of the considered input range for the question. For date questions, this is a unix timestamp.
+ Range Max:
+ type: number
+ format: float
+ description: The maximum value of the considered input range for the question. For date questions, this is a unix timestamp.
+ Zero Point:
+ type: number
+ format: float
+ description: The zero point of the question. For date questions, this is a unix timestamp.
+ Open Lower Bound:
+ type: boolean
+ description: Whether the lower bound of the range is open or not.
+ Open Upper Bound:
+ type: boolean
+ description: Whether the upper bound of the range is open or not.
+ Open Time:
+ type: string
+ format: date-time
+ description: The time the question was opened.
+ CP Reveal Time:
+ type: string
+ format: date-time
+ description: The time the CP was revealed. This is also the default setting for when Spot Scores are evaluated.
+ Scheduled Close Time:
+ type: string
+ format: date-time
+ description: The time the question is scheduled to close.
+ Actual Close Time:
+ type: string
+ format: date-time
+ description: The time the question actually closed.
+ Resolution:
+ type: string
+ description: The nominal resolution of the question. E.g. 'no', 'yes' for binary, '2022-01-01' for date, etc.
+ Resolution Known Time:
+ type: string
+ format: date-time
+ description: The time the question resolution was publicly known.
+ Include Bots in Aggregates:
+ type: boolean
+ description: Whether bots are included in the aggregates.
+ ForecastDataCSV:
+ type: object
+ properties:
+ Question ID:
+ type: integer
+ description: The ID of the question this forecast is made on.
+ example: 3
+ Forecaster ID:
+ type: integer
+ description: The forecaster's ID. None if forecast is an Aggregation.
+ example: 421323
+ Forecaster Username:
+ type: string
+ description: The forecaster's username or aggregation method.
+ example: John Doe
+ Start Time:
+ type: string
+ format: date-time
+ description: The start time of the prediction
+ End Time:
+ type: string
+ format: date-time
+ description: The end time of the prediction
+ Forecaster Count:
+ type: integer
+ description: The number of forecasts that contributed to this Aggregation. null if not an Aggregation.
+ example: 3
+ Probability Yes:
+ type: number
+ format: float
+ description: For Binary Questions only. The prediction value in range [0, 1]
+ example: 0.5
+ Probability Yes Per Category:
+ type: array
+ items:
+ format: number
+ type: float
+ description: For Multiple Choice Questions only. The predictions for the possible outcomes all in range [0, 1]
+ example: [0.2, 0.5, 0.3]
+ Continuous CDF:
+ type: array
+ items:
+ format: number
+ type: float
+ description: For Continuous Questions only. The CDF of the prediction.
+ example: [0.01, 0.02, 0.03, ..., 0.74, 0.75]
+ ScoreDataCSV:
+ type: object
+ properties:
+ Question ID:
+ type: integer
+ description: The ID of the question the score is for.
+ example: 42
+ User ID:
+ type: integer
+ description: The ID of the user who made the score.
+ example: 12345
+ User Username:
+ type: string
+ description: The username of the user who made the score.
+ example: "john_doe"
+ Score Type:
+ type: string
+ enum: [ relative_legacy, peer, baseline, spot_peer, spot_baseline, manual ]
+ description: The type of score.
+ example: "peer"
+ Score:
+ type: number
+ format: float
+ description: The score value.
+ example: 85.5
+ Coverage:
+ type: number
+ format: float
+ description: The coverage that earned this score.
+ example: 0.95
+ CommentDataCSV:
+ type: object
+ properties:
+ Post ID:
+ type: integer
+ description: The ID of the post the comment is on.
+ example: 101
+ Author ID:
+ type: integer
+ description: The ID of the author of the comment.
+ example: 202
+ Author Username:
+ type: string
+ description: The username of the author of the comment.
+ example: "jane_doe"
+ Parent Comment ID:
+ type: integer
+ description: The ID of the parent comment, if any.
+ example: 303
+ Root Comment ID:
+ type: integer
+ description: The ID of the root comment, if any.
+ example: 404
+ Created At:
+ type: string
+ format: date-time
+ description: The time the comment was created.
+ example: "2023-10-01T12:34:56Z"
+ Comment Text:
+ type: string
+ description: The text of the comment.
+ example: "This is a sample comment text."
+
+
+ DataZip:
+ type: object
+ description: A zip file containing CSVs for Question data, Forecast data, and (optionally) Scoring data and Comment data.
+ properties:
+ question_data.csv:
+ $ref: '#/components/schemas/QuestionDataCSV'
+ forecast_data.csv:
+ $ref: '#/components/schemas/ForecastDataCSV'
+ score_data.csv:
+ $ref: '#/components/schemas/ScoreDataCSV'
+ comment_data.csv:
+ $ref: '#/components/schemas/CommentDataCSV'
+
+tags:
+ - name: Feed
+ description: |
+ In the updated version of Metaculus, standalone questions no longer exist. Instead, the feed is made up of Post entities, where each post can contain various types of content objects. These include:
+
+ - Individual questions
+ - Groups of questions
+ - Conditional pairs
+ - Notebooks
+
+ The primary entry point for retrieving feed data and interacting with both Posts and Questions is the `/api/posts` endpoint.
+ - name: Questions & Forecasts
+ description: |
+
+ Guide to Generating a Continuous CDF
+
+ ## Generating a Continuous CDF
+
+ Generating a CDF to predict on a continuous question can be a bit tricky. We only accept predictions in the form of a 201 point
+ CDF, represented as a list of floats, so getting scaling right is very important.
+
+ To start off, let's take an example question: "What will be the temperature (in Celcius) in New York City on January 1st, 3000?"
+
+ If you were to open the question on the website, you would see that the prediction input will have an x-axis that goes from `-40` to `110`.
+ You could also find those values in the api by looking at the `"range_min"` and `"range_max"` properties inside `question["scaling"]`.
+ If you want to build a cdf, what does that mean to you? It means that you'll need to generate the heights of the cdf at 201 points
+ representing locations between -40 and 110. In this case, the first value of the CDF array we are going to build will be the probability
+ that the temperature is less than (not equal to) to -40. The last value will represent the probability that the temperature is less
+ than or equal to 110. We then see that the `"zero_point"` in `question["scaling"]` is None, so we can conclude that the remaining
+ values in the CDF array represent the height of the cdf at equally spaced locations between -40 and 110. This is because the `"zero_point"`
+ being empty means that the question scaling is linear.
+
+ Here's a function that can take in a nominal location and return the corresponding "internal location" x-value. Note that it
+ does account for logarithmic scaling.
+ ```python
+ import datetime
+ import numpy as np
+
+ def nominal_location_to_cdf_location(
+ nominal_location: str | float,
+ question_data: dict,
+ ) -> float:
+ """Takes a location in nominal format (e.g. 123, "123",
+ or datetime in iso format) and scales it to metaculus's
+ "internal representation" range [0,1] incorporating question scaling"""
+ if question_data["type"] == "date":
+ scaled_location = datetime.fromisoformat(nominal_location).timestamp()
+ else:
+ scaled_location = float(nominal_location)
+ # Unscale the value to put it into the range [0,1]
+ scaling = question_data["scaling"]
+ range_min = scaling.get("range_min")
+ range_max = scaling.get("range_max")
+ zero_point = scaling.get("zero_point")
+ if zero_point is not None:
+ # logarithmically scaled question
+ deriv_ratio = (range_max - zero_point) / (range_min - zero_point)
+ unscaled_location = (
+ np.log(
+ (scaled_location - range_min) * (deriv_ratio - 1)
+ + (range_max - range_min)
+ )
+ - np.log(range_max - range_min)
+ ) / np.log(deriv_ratio)
+ else:
+ # linearly scaled question
+ unscaled_location = (scaled_location - range_min) / (range_max - range_min)
+ return unscaled_location
+ ```
+
+ But you don't want to have to do this manually for every point in a 201 point CDF, so instead let's do it with percentiles.
+ Let's say that we have a solid idea about what our 5th, 25th, 50th, 75th, and 95th percentiles are in real units. We can use those to
+ generate a CDF. But, note that if our 5th and 95th percentiles don't overlap the range of the question, we'll have to add a few
+ more bits of information to complete our CDF.
+
+ Here's a function that can take in some percentiles and return an inferred linearly interpolated CDF.
+ ```python
+ def generate_continuous_cdf(
+ percentiles: dict,
+ question_data: dict,
+ below_lower_bound: float = None,
+ above_upper_bound: float = None,
+ ) -> list[float]:
+ """
+ Takes a set of percentiles and returns a corresponding cdf with 201 values
+
+ Param: percentiles
+ dict[str, float | str]
+ keys must terminate in a number interpretable as a float in range (0, 100)
+ optionally preceded by an underscore "_"
+ values must be a nominal value in the scale of the question, either
+ interpretable as a float (for "numeric" type questions) or a datetime in
+ ISO format (for "date" type questions)
+ example percentiles:
+ percentiles = {
+ "percentile_01": 25,
+ "precentile_25.123": 500,
+ "50": 650,
+ "percentile_75": "700",
+ "percentile_99": 990,
+ }
+ optionally, include `below_lower_bound` and `above_upper_bound`
+ to indicate the amount of probability mass assigned to those locations
+ percentiles = {
+ "percentile_25": 500,
+ "percentile_50": 650,
+ "percentile_75": 700,
+ }
+ below_lower_bound = 0.0025,
+ above_upper_bound = 0.009,
+
+ If the percentile locations don't encompass
+ [scaling["range_min"], scaling["range_max"]]
+ and "below_lower_bound"/"above_upper_bound" aren't provided,
+ then the prediction can't be interpreted as a cdf properly.
+ Note that range_min/range_max for date questions are unix timestamps.
+ """
+
+ # This will be the set of (x, y) points that are the set points
+ # of the cdf
+ percentile_locations = []
+
+ # take the given boundary values
+ if below_lower_bound is not None:
+ percentile_locations.append((0.0, below_lower_bound))
+ if above_upper_bound is not None:
+ percentile_locations.append((1.0, 1 - above_upper_bound))
+
+ # generate the remaining set of points
+ for percentile, nominal_location in percentiles.items():
+ height = float(str(percentile).split("_")[-1]) / 100
+ location = nominal_location_to_cdf_location(nominal_location, question_data)
+ percentile_locations.append((location, height))
+
+ # sort to ensure lookup works
+ percentile_locations.sort()
+
+ # check validity
+ first_point, last_point = percentile_locations[0], percentile_locations[-1]
+ if (first_point[0] > 0.0) or (last_point[0] < 1.0):
+ raise ValueError("Percentiles must encompass bounds of the question")
+
+ def get_cdf_at(location):
+ # helper function that takes a location and returns
+ # the height of the cdf at that location, linearly
+ # interpolating between values
+ previous = percentile_locations[0]
+ for i in range(1, len(percentile_locations)):
+ current = percentile_locations[i]
+ if previous[0] <= location <= current[0]:
+ return previous[1] + (current[1] - previous[1]) * (
+ location - previous[0]
+ ) / (current[0] - previous[0])
+ previous = current
+
+ # generate that cdf
+ continuous_cdf = [get_cdf_at(i / 200) for i in range(201)]
+ return continuous_cdf
+ ```
+
+ Phew, that was pretty long, but now we can take a set of nominal percentiles and generate a CDF that is in the format
+ that the Metaculus api will accept. But wait! Just because we have a cdf that represents out beliefs, it doesn't
+ mean Metaculus will accept it. We'll have to make sure it obeys a few rules, lest it be rejected as invalid.
+
+ 1. The cdf must be strictly increasing by at least 0.00005 per step. This is because Metaculus evaluates continuous forecasts
+ by their PDF (technically a PMF) dervied as the set of differences between consecutive CDF points, and 0.00005 is the
+ minimum value allowed to avoid scores getting too arbitrarily negative.
+
+ 2. The cdf must not increase by more than 0.59 at any step, as this is the maximum value attainable via the sliders in the UI.
+ This threshold may be lowered in the future.
+
+ 3. The cdf must obey bounds. If a boundary is open, at least 0.1% of probability mass must be assigned outside of it; if it
+ is closed, no probability mass may be outside of it.
+
+ Here's a standardization function that ensures your CDF will be accepted. It does add a linear component to the cdf,
+ so those with an abundance of precision in their forecasts way want to skip it. The cdfs (and thus their derived pdfs)
+ you see on the website have been standardized in this way.
+ ```python
+ def standardize_cdf(cdf: list[float], question_data: dict) -> list[float]:
+ """
+ Takes a cdf and returns a standardized version of it
+
+ - assigns no mass outside of closed bounds (scales accordingly)
+ - assigns at least a minimum amount of mass outside of open bounds
+ - increasing by at least the minimum amount (0.01 / 200 = 0.0005)
+
+ TODO: add smoothing over cdfs that spike too heavily (exceed a change of 0.59)
+ """
+ lower_open = question_data["open_lower_bound"]
+ upper_open = question_data["open_upper_bound"]
+
+ scale_lower_to = 0 if lower_open else cdf[0]
+ scale_upper_to = 1.0 if upper_open else cdf[-1]
+ rescaled_inbound_mass = scale_upper_to - scale_lower_to
+
+ def standardize(F: float, location: float) -> float:
+ # `F` is the height of the cdf at `location` (in range [0, 1])
+ # rescale
+ rescaled_F = (F - scale_lower_to) / rescaled_inbound_mass
+ # offset
+ if lower_open and upper_open:
+ return 0.988 * rescaled_F + 0.01 * location + 0.001
+ elif lower_open:
+ return 0.989 * rescaled_F + 0.01 * location + 0.001
+ elif upper_open:
+ return 0.989 * rescaled_F + 0.01 * location
+ return 0.99 * rescaled_F + 0.01 * location
+
+ standardized_cdf = []
+ for i, F in enumerate(cdf):
+ standardized_F = standardize(F, i / (len(cdf) - 1))
+ # round to avoid floating point errors
+ standardized_cdf.append(round(standardized_F, 10))
+
+ return standardized_cdf
+ ```
+
+ With this tiny guide, you can be well along your way to submitting continuous forecasts to Metaculus.
+ If you have any questions or suggestions, feel free to create tickets on the github: https://github.com/Metaculus/metaculus/issues
+
+ - name: Comments
+ - name: Utilities & Data
+paths:
+ /posts/:
+ get:
+ operationId: GetPosts
+ summary: Retrieve posts feed
+ description: Retrieves a feed of posts with various filters.
+ tags:
+ - Feed
+ parameters:
+ - in: query
+ name: tournaments
+ schema:
+ type: array
+ items:
+ type: string
+ example: [ 'quarterly-cup', 'aibq3' ]
+ description: Tournament slug. You can apply multiple filters.
+ - in: query
+ name: statuses
+ schema:
+ type: array
+ items:
+ type: string
+ enum: [ upcoming, closed, resolved, open ]
+ example: [ 'closed', 'resolved' ]
+ description: Post statuses. You can apply multiple filters.
+ - in: query
+ name: forecaster_id
+ schema:
+ type: integer
+ example: 123
+ description: Filters posts where the specified user has submitted a forecast on a question within the post.
+ - in: query
+ name: with_cp
+ schema:
+ type: boolean
+ example: true
+ description: "When this flag is enabled, each post with a group of questions will return community predictions for only the top 3 subquestions. To retrieve predictions for all subquestions, please use the post details endpoint."
+ - in: query
+ name: not_forecaster_id
+ schema:
+ type: integer
+ example: 123
+ description: Filters posts where the specified user has not submitted a forecast on a question within the post.
+ - in: query
+ name: open_time__gt
+ schema:
+ type: string
+ format: date-time
+ example: '2024-01-01'
+ description: "Post open timestamp filter: `open_time__`. Supported operators: `gt`, `gte`, `lt`, `lte`."
+ - in: query
+ name: published_at__gt
+ schema:
+ type: string
+ format: date-time
+ example: '2024-01-01'
+ description: "Post publication timestamp filter: `published_at__`. Supported operators: `gt`, `gte`, `lt`, `lte`."
+ - in: query
+ name: scheduled_resolve_time__gt
+ schema:
+ type: string
+ format: date-time
+ example: '2024-01-01'
+ description: "Scheduled resolution timestamp filter: `scheduled_resolve_time__`. Supported operators: `gt`, `gte`, `lt`, `lte`."
+ - in: query
+ name: forecast_type
+ schema:
+ type: array
+ items:
+ type: string
+ enum: [ binary, numeric, date, multiple_choice, conditional, group_of_questions, notebook ]
+ example: [ 'numeric', 'binary' ]
+ description: Forecast type. You can apply multiple filters.
+ - in: query
+ name: order_by
+ schema:
+ type: string
+ enum:
+ - published_at
+ - open_time
+ - vote_score
+ - comment_count
+ - forecasts_count
+ - scheduled_close_time
+ - scheduled_resolve_time
+ - user_last_forecasts_date
+ - unread_comment_count
+ - weekly_movement
+ - divergence
+ - hotness
+ - score
+ example: '-published_at'
+ description: Order by specific fields. For DESC sorting, add `-` prefix, e.g. `-published_at`.
+ responses:
+ '200':
+ description: A paginated list of posts
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ next:
+ type: string
+ nullable: true
+ example: "https://metaculus.com/api/posts/?forecast_type=binary&limit=1&offset=1"
+ previous:
+ type: string
+ nullable: true
+ example: null
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/Post'
+ examples:
+ NumericRequestExample:
+ summary: Post With Numeric Question
+ value:
+ next: "https://metaculus.com/api/posts/"
+ previous: null
+ results:
+ - id: 3530
+ title: "How many people will die as a result of the 2019 novel coronavirus (COVID-19) before 2021?"
+ url_title: "COVID-19 Related Deaths before 2021"
+ slug: "covid-19-related-deaths-before-2021"
+ author_id: 101465
+ author_username: "Jgalt"
+ projects:
+ category:
+ - id: 3685
+ name: "Health & Pandemics"
+ slug: "health-pandemics"
+ description: "Health & Pandemics"
+ tag:
+ - id: 5262
+ name: "Public health"
+ slug: "public-health"
+ default_project:
+ id: 144
+ type: "site_main"
+ name: "Metaculus Community"
+ slug: null
+ prize_pool: "0.00"
+ start_date: null
+ close_date: null
+ meta_description: ""
+ is_ongoing: null
+ user_permission: null
+ created_at: "2023-11-08T16:55:29.484707Z"
+ edited_at: "2023-11-08T16:55:29.537784Z"
+ default_permission: "forecaster"
+ visibility: "normal"
+ topic:
+ - id: 15858
+ name: "Health & Pandemics"
+ slug: "biosecurity"
+ emoji: "🧬"
+ section: "hot_categories"
+ created_at: "2020-01-25T04:09:23.208127Z"
+ published_at: "2020-01-27T00:00:00Z"
+ edited_at: "2024-10-03T08:38:21.381658Z"
+ curation_status: "approved"
+ comment_count: 270
+ status: "resolved"
+ resolved: true
+ actual_close_time: "2020-11-01T00:00:00Z"
+ scheduled_close_time: "2020-11-01T00:00:00Z"
+ scheduled_resolve_time: "2022-05-06T16:00:00Z"
+ open_time: "2020-01-27T00:00:00Z"
+ nr_forecasters: 546
+ question:
+ id: 3530
+ title: "How many people will die as a result of the 2019 novel coronavirus (COVID-19) before 2021?"
+ description: ""
+ created_at: "2020-01-25T04:09:23.208127Z"
+ open_time: "2020-01-27T00:00:00Z"
+ scheduled_resolve_time: "2022-05-06T16:00:00Z"
+ actual_resolve_time: "2022-05-06T16:00:00Z"
+ resolution_set_time: "2022-05-06T16:00:00Z"
+ scheduled_close_time: "2020-11-01T00:00:00Z"
+ actual_close_time: "2020-11-01T00:00:00Z"
+ type: "numeric"
+ options: null
+ status: "resolved"
+ possibilities:
+ low: "tail"
+ high: "tail"
+ type: "continuous"
+ scale:
+ max: 100000000
+ min: 200
+ deriv_ratio: 500000
+ format: "num"
+ resolution: "77289125.94957079"
+ resolution_criteria: "Resolution Criteria Copy"
+ fine_print: ""
+ label: null
+ open_upper_bound: true
+ open_lower_bound: true
+ scaling:
+ range_max: 100000000.0
+ range_min: 200.0
+ zero_point: 0.0
+ post_id: 3530
+ aggregations:
+ recency_weighted:
+ history:
+ - start_time: 1580307151.25848
+ end_time: 1580486372.895453
+ forecast_values: null
+ forecaster_count: 24
+ interval_lower_bounds:
+ - 0.12959500321439227
+ centers:
+ - 0.25918927393346786
+ interval_upper_bounds:
+ - 0.4362854344258292
+ means: null
+ histogram: null
+ latest:
+ start_time: 1604186008.116567
+ end_time: null
+ forecast_values:
+ - 0.0023223077886532994
+ - 0.002387641461850537
+ - 0.0024531345535807863
+ forecaster_count: 545
+ interval_lower_bounds:
+ - 0.6809976958737698
+ centers:
+ - 0.703534857578826
+ interval_upper_bounds:
+ - 0.7304231105944988
+ means: null
+ histogram: null
+ score_data:
+ peer_score: 51.665686599518814
+ coverage: 0.9980877695216895
+ baseline_score: 35.14605488073114
+ spot_peer_score: 57.555204604943135
+ peer_archived_score: 51.665686599518814
+ baseline_archived_score: 35.14605488073114
+ spot_peer_archived_score: 57.555204604943135
+ unweighted:
+ history: [ ]
+ latest: null
+ score_data: { }
+ single_aggregation:
+ history: [ ]
+ latest: null
+ score_data: { }
+ metaculus_prediction:
+ history:
+ - start_time: 1580129296.168167
+ end_time: 1580280255.056228
+ forecast_values: null
+ forecaster_count: 1
+ interval_lower_bounds:
+ - 0.15523
+ centers:
+ - 0.17581
+ interval_upper_bounds:
+ - 0.19643
+ means: null
+ histogram: null
+ latest:
+ start_time: 1604186009.588382
+ end_time: null
+ forecast_values:
+ - 0.01088
+ - 0.010930492245919658
+ - 0.010980984491839312
+ forecaster_count: 545
+ interval_lower_bounds:
+ - 0.69824
+ centers:
+ - 0.71735
+ interval_upper_bounds:
+ - 0.73726
+ means: null
+ histogram: null
+ score_data: { }
+ user_permission: "forecaster"
+ vote:
+ score: 172
+ user_vote: null
+ forecasts_count: 2760
+ BinaryRequestExample:
+ summary: Post With Binary Question
+ value:
+ next: "https://www.metaculus.com/api/posts/"
+ previous: null
+ results:
+ - id: 1
+ title: "Will advanced LIGO announce discovery of gravitational waves by Jan. 31 2016?"
+ url_title: ""
+ slug: "will-advanced-ligo-announce-discovery-of-gravitational-waves-by-jan-31-2016"
+ author_id: 8
+ author_username: "Anthony"
+ coauthors: [ ]
+ projects: { }
+ created_at: "2015-10-02T02:35:57.581979Z"
+ published_at: "2015-10-02T02:34:00Z"
+ edited_at: "2024-10-03T07:58:31.800928Z"
+ curation_status: "approved"
+ comment_count: 5
+ status: "resolved"
+ resolved: true
+ actual_close_time: "2015-12-15T03:34:00Z"
+ scheduled_close_time: "2015-12-15T03:34:00Z"
+ scheduled_resolve_time: "2016-02-01T03:34:00Z"
+ open_time: "2015-10-02T02:34:00Z"
+ nr_forecasters: 10
+ question:
+ id: 1
+ title: "Will advanced LIGO announce discovery of gravitational waves by Jan. 31 2016?"
+ description: ""
+ created_at: "2015-10-02T02:35:57.581979Z"
+ open_time: "2015-10-02T02:34:00Z"
+ scheduled_resolve_time: "2016-02-01T03:34:00Z"
+ actual_resolve_time: "2016-02-01T03:34:00Z"
+ resolution_set_time: "2016-02-01T03:34:00Z"
+ scheduled_close_time: "2015-12-15T03:34:00Z"
+ actual_close_time: "2015-12-15T03:34:00Z"
+ type: "binary"
+ options: null
+ status: "resolved"
+ possibilities:
+ type: "binary"
+ resolution: "no"
+ resolution_criteria: "Resolution Criteria Copy"
+ fine_print: ""
+ label: null
+ open_upper_bound: null
+ open_lower_bound: null
+ scaling:
+ range_max: null
+ range_min: null
+ zero_point: null
+ post_id: 1
+ aggregations: { }
+ user_permission: "forecaster"
+ vote:
+ score: 6
+ user_vote: null
+ forecasts_count: 16
+ MultipleChoiceRequestExample:
+ summary: Post With Multiple-Choice Question
+ value:
+ next: "https://www.metaculus.com/api/posts/"
+ previous: null
+ results:
+ - id: 20772
+ title: "Which party will win the 2024 US presidential election?"
+ url_title: "Party Winning the Presidency in 2024?"
+ slug: "party-winning-the-presidency-in-2024"
+ author_id: 117502
+ author_username: "RyanBeck"
+ coauthors: [ ]
+ projects: { }
+ created_at: "2023-12-22T04:14:45.479858Z"
+ published_at: "2024-01-01T07:00:00Z"
+ edited_at: "2024-10-03T09:16:21.660122Z"
+ curation_status: "approved"
+ comment_count: 12
+ status: "open"
+ resolved: false
+ actual_close_time: null
+ scheduled_close_time: "2024-11-07T16:00:00Z"
+ scheduled_resolve_time: "2025-01-06T14:00:00Z"
+ open_time: "2024-01-01T07:00:00Z"
+ nr_forecasters: 984
+ question:
+ id: 20772
+ title: "Which party will win the 2024 US presidential election?"
+ description: "Long description"
+ created_at: "2023-12-22T04:14:45.479858Z"
+ open_time: "2024-01-01T07:00:00Z"
+ scheduled_resolve_time: "2025-01-06T14:00:00Z"
+ actual_resolve_time: null
+ resolution_set_time: null
+ scheduled_close_time: "2024-11-07T16:00:00Z"
+ actual_close_time: "2024-11-07T16:00:00Z"
+ type: "multiple_choice"
+ options:
+ - "Democratic"
+ - "Republican"
+ - "Libertarian"
+ - "Green"
+ - "Other"
+ status: "open"
+ possibilities: { }
+ resolution: null
+ resolution_criteria: "Resolution Criteria Copy"
+ fine_print: "Fine Print Copy"
+ label: null
+ open_upper_bound: null
+ open_lower_bound: null
+ scaling: { }
+ post_id: 20772
+ aggregations: { }
+ user_permission: "forecaster"
+ vote:
+ score: 36
+ user_vote: null
+ forecasts_count: 2057
+ ConditionalRequestExample:
+ summary: Post With Conditional Questions
+ value:
+ next: "https://www.metaculus.com/api/posts/"
+ previous: null
+ results:
+ - id: 21475
+ title: "2024 Democratic Presidential Nominee? (Joe Biden) → Democrat Wins 2024 US Presidential Election?"
+ url_title: "Democrat Wins 2024 US Presidential Election?"
+ slug: "democrat-wins-2024-us-presidential-election"
+ author_id: 130973
+ author_username: "NMorrison"
+ coauthors: [ ]
+ projects: { }
+ created_at: "2024-02-21T01:37:23.166501Z"
+ published_at: "2024-02-21T07:00:00Z"
+ edited_at: "2024-09-30T15:59:05.961173Z"
+ curation_status: "approved"
+ comment_count: 7
+ status: "open"
+ resolved: false
+ actual_close_time: null
+ scheduled_close_time: "2024-11-05T13:00:00Z"
+ scheduled_resolve_time: "2025-01-21T05:00:00Z"
+ open_time: "2021-03-11T05:00:00Z"
+ nr_forecasters: 121
+ conditional:
+ id: 21475
+ condition:
+ id: 5712
+ title: "Who will be the Democratic nominee for the 2024 US Presidential Election? (Joe Biden)"
+ description: "Description"
+ created_at: "2020-11-13T05:21:18.530122Z"
+ open_time: "2021-03-11T05:00:00Z"
+ scheduled_resolve_time: "2024-09-01T04:00:00Z"
+ actual_resolve_time: "2024-08-21T01:30:00Z"
+ resolution_set_time: "2024-08-21T01:30:00Z"
+ scheduled_close_time: "2024-09-01T04:00:00Z"
+ actual_close_time: "2024-08-21T01:30:00Z"
+ type: "binary"
+ options: null
+ status: "resolved"
+ possibilities:
+ type: "binary"
+ resolution: "no"
+ resolution_criteria: "resolution_criteria"
+ fine_print: "fine_print"
+ label: null
+ open_upper_bound: null
+ open_lower_bound: null
+ scaling:
+ range_max: null
+ range_min: null
+ zero_point: null
+ post_id: 11379
+ aggregations: { }
+ condition_child:
+ id: 6478
+ title: "Will a Democrat win the 2024 US presidential election?"
+ description: "Description"
+ created_at: "2021-02-03T17:46:46.976981Z"
+ open_time: "2021-02-08T05:00:00Z"
+ scheduled_resolve_time: "2025-01-21T05:00:00Z"
+ actual_resolve_time: null
+ resolution_set_time: null
+ scheduled_close_time: "2024-11-05T13:00:00Z"
+ actual_close_time: "2024-11-05T13:00:00Z"
+ type: "binary"
+ options: null
+ status: "open"
+ possibilities:
+ type: "binary"
+ resolution: null
+ resolution_criteria: "Resolution Criteria"
+ fine_print: ""
+ label: null
+ open_upper_bound: null
+ open_lower_bound: null
+ scaling:
+ range_max: null
+ range_min: null
+ zero_point: null
+ post_id: 6478
+ aggregations: { }
+ question_yes:
+ id: 21477
+ title: "2024 Democratic Presidential Nominee (Joe Biden) (Yes) → Democrat Wins 2024 US Presidential Election?"
+ description: ""
+ created_at: "2024-02-21T01:37:24.033782Z"
+ open_time: "2024-02-21T07:00:00Z"
+ scheduled_resolve_time: "2024-09-01T04:00:00Z"
+ actual_resolve_time: "2024-08-21T01:30:00Z"
+ resolution_set_time: "2024-08-21T01:30:00Z"
+ scheduled_close_time: "2024-09-01T04:00:00Z"
+ actual_close_time: "2024-08-21T01:30:00Z"
+ type: "binary"
+ options: null
+ status: "resolved"
+ possibilities:
+ type: "binary"
+ resolution: "annulled"
+ resolution_criteria: ""
+ fine_print: ""
+ label: null
+ open_upper_bound: null
+ open_lower_bound: null
+ scaling:
+ range_max: null
+ range_min: null
+ zero_point: null
+ post_id: 21475
+ aggregations: { }
+ question_no:
+ id: 21476
+ title: "2024 Democratic Presidential Nominee? (Joe Biden) (No) → Democrat Wins 2024 US Presidential Election?"
+ description: ""
+ created_at: "2024-02-21T01:37:24.033665Z"
+ open_time: "2024-02-21T07:00:00Z"
+ scheduled_resolve_time: "2025-01-21T05:00:00Z"
+ actual_resolve_time: null
+ resolution_set_time: null
+ scheduled_close_time: "2024-09-01T04:00:00Z"
+ actual_close_time: "2024-08-21T01:30:00Z"
+ type: "binary"
+ options: null
+ status: "closed"
+ possibilities:
+ type: "binary"
+ resolution: null
+ resolution_criteria: ""
+ fine_print: ""
+ label: null
+ open_upper_bound: null
+ open_lower_bound: null
+ scaling:
+ range_max: null
+ range_min: null
+ zero_point: null
+ post_id: 21475
+ aggregations: { }
+ user_permission: "forecaster"
+ vote:
+ score: 25
+ user_vote: null
+ forecasts_count: 466
+ GroupOfQuestionsRequestExample:
+ summary: Post With Group Of Questions
+ value:
+ next: "https://www.metaculus.com/api/posts/"
+ previous: null
+ results:
+ - id: 11480
+ title: "Will China launch a full-scale invasion of Taiwan by the following years?"
+ url_title: "Chinese Invasion of Taiwan?"
+ slug: "chinese-invasion-of-taiwan"
+ author_id: 104161
+ author_username: "casens"
+ coauthors: [ ]
+ projects: { }
+ created_at: "2022-06-21T17:44:44.092940Z"
+ published_at: "2022-05-06T04:00:00Z"
+ edited_at: "2024-09-29T14:14:15.433250Z"
+ curation_status: "approved"
+ comment_count: 338
+ status: "open"
+ resolved: false
+ actual_close_time: null
+ scheduled_close_time: "2035-01-01T05:00:00Z"
+ scheduled_resolve_time: "2035-01-01T05:00:00Z"
+ open_time: "2022-05-06T04:00:00Z"
+ nr_forecasters: 764
+ group_of_questions:
+ id: 11480
+ description: "description"
+ resolution_criteria: "resolution_criteria"
+ fine_print: ""
+ group_variable: "Date"
+ graph_type: "multiple_choice_graph"
+ questions:
+ - id: 10880
+ title: "Will China launch a full-scale invasion of Taiwan by the following years? (2030)"
+ description: "description"
+ created_at: "2022-05-08T03:15:15.689218Z"
+ open_time: "2022-05-10T04:00:00Z"
+ scheduled_resolve_time: "2030-01-01T05:00:00Z"
+ actual_resolve_time: null
+ resolution_set_time: null
+ scheduled_close_time: "2030-01-01T05:00:00Z"
+ actual_close_time: "2030-01-01T05:00:00Z"
+ type: "binary"
+ options: null
+ status: "open"
+ possibilities:
+ type: "binary"
+ resolution: null
+ resolution_criteria: "resolution_criteria"
+ fine_print: ""
+ label: null
+ open_upper_bound: null
+ open_lower_bound: null
+ scaling:
+ range_max: null
+ range_min: null
+ zero_point: null
+ post_id: 11480
+ aggregations: { }
+ - id: 10923
+ title: "Will China launch a full-scale invasion of Taiwan by the following years? (2035)"
+ description: "description"
+ created_at: "2022-05-10T19:13:46.688335Z"
+ open_time: "2022-05-13T04:00:00Z"
+ scheduled_resolve_time: "2035-01-01T05:00:00Z"
+ actual_resolve_time: null
+ resolution_set_time: null
+ scheduled_close_time: "2035-01-01T05:00:00Z"
+ actual_close_time: "2035-01-01T05:00:00Z"
+ type: "binary"
+ options: null
+ status: "open"
+ possibilities:
+ type: "binary"
+ resolution: null
+ resolution_criteria: "resolution_criteria"
+ fine_print: ""
+ label: null
+ open_upper_bound: null
+ open_lower_bound: null
+ scaling:
+ range_max: null
+ range_min: null
+ zero_point: null
+ post_id: 11480
+ aggregations: { }
+ user_permission: "forecaster"
+ vote:
+ score: 69
+ user_vote: null
+ forecasts_count: 4670
+ /posts/{postId}/:
+ get:
+ operationId: GetPost
+ summary: Retrieve post details
+ tags:
+ - Feed
+ parameters:
+ - name: postId
+ in: path
+ required: true
+ schema:
+ type: integer
+ description: The ID of the post to retrieve
+ responses:
+ '200':
+ description: Post details
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Post'
+ /posts/{postId}/download-data/:
+ get:
+ summary: Download data for a Question. Will open a download prompt in the browser. The return is a Zip file of CSVs.
+ tags:
+ - Utilities & Data
+ parameters:
+ - name: postId
+ in: path
+ required: true
+ schema:
+ type: integer
+ description: The ID of the post to retrieve. This is the number after `/questions/` in the URL of a question detail page.
+ - name: sub_question
+ in: query
+ required: false
+ schema:
+ type: integer
+ description: If this is a group or conditional question, and only a certain sub-question is needed, specify the sub-question ID.
+ - name: aggregation_methods
+ in: query
+ required: false
+ schema:
+ type: array
+ items:
+ type: string
+ enum: [ recency_weighted, unweighted, metaculus_prediction, single_aggregation ]
+ description: Aggregation methods to include in the CSV, no argument will only include recency_weighted. single_aggregation is
+ in beta and not available except to site admins. Including this parameter will trigger a recalculation of the aggregations,
+ which may increase server response time. If the server times out, please consider removing this parameter.
+ - name: include_bots
+ in: query
+ required: false
+ schema:
+ type: boolean
+ description: If given, "aggregation_methods" parameter is also required. If not given, will take the value of the
+ "include_bots_in_aggregations" property on the given Question. If true, will include bot forecasts in the recalculation
+ of the aggregations, and not if False.
+ - name: user_ids
+ in: query
+ required: false
+ schema:
+ type: array
+ items:
+ type: string
+ description: A list of user IDs. May only be given if the request user is authenticated, and whitelisted to download user-level
+ forecast data. If given, "aggregation_methods" parameter is also required. The list of given user ids will recalculate the
+ aggregations given only those users' forecasts.
+ - name: minimize
+ in: query
+ required: false
+ schema:
+ type: boolean
+ description: Defaults to True. If False, "aggregation_methods" parameter is also required. If False, will include all data
+ points in the recalculated aggregations. For questions with many forecasts, this may result in a very large file, or
+ cause a server timeout. If the server times out, please consider removing this paramater.
+ - name: include_comments
+ in: query
+ required: false
+ schema:
+ type: boolean
+ description: Defaults to False. If True, will include a CSV file containing all public comments.
+ - name: include_scores
+ in: query
+ required: false
+ schema:
+ type: boolean
+ description: Defaults to False. If True, will include a CSV file containing all scores.
+ responses:
+ '200':
+ description: Zip data for the question. Contains question_data.csv and forecast_data.csv, and optionally comment_data.csv and score_data.csv
+ content:
+ application/zip:
+ schema:
+ $ref: '#/components/schemas/DataZip'
+ /projects/{projectId}/download-data/:
+ get:
+ summary: Download data for a whole Project. Will open a download prompt in the browser. The return is a Zip file of CSVs. Only available to site admins and Whitelisted users.
+ tags:
+ - Utilities & Data
+ parameters:
+ - name: projectId
+ in: path
+ required: true
+ schema:
+ type: integer
+ description: The ID of the Project to retrieve.
+ - name: include_comments
+ in: query
+ required: false
+ schema:
+ type: boolean
+ description: Defaults to False. If True, will include a CSV file containing all public comments.
+ - name: include_scores
+ in: query
+ required: false
+ schema:
+ type: boolean
+ description: Defaults to False. If True, will include a CSV file containing all scores.
+ responses:
+ '200':
+ description: Zip data for the questions in the project. Contains question_data.csv and forecast_data.csv, and optionally comment_data.csv and score_data.csv
+ content:
+ application/zip:
+ schema:
+ $ref: '#/components/schemas/DataZip'
+
+ /questions/forecast/:
+ post:
+ operationId: SubmitForecast
+ summary: Submit forecasts for questions
+ description: This endpoint supports multiple simultaneous predictions, so the base object is a list of foreacsts.
+ Pass one forecast object for single questions and multiple forecast objects for group of questions or conditional forecasts.
+ tags:
+ - Questions & Forecasts
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Forecast'
+ examples:
+ BinaryQuestionExample:
+ summary: Binary Question
+ value:
+ - question: 1
+ probability_yes: 0.63
+ ContinuousQuestionExample1:
+ summary: Continuous Question - closed bounds
+ value:
+ - question: 1
+ continuous_cdf: [
+ 0.00000, 0.00005, 0.00010, 0.00015, 0.00020, 0.00025, 0.00030, 0.00035,
+ 0.00040, 0.00045, 0.00050, 0.00055, 0.00060, 0.00065, 0.00070, 0.00075,
+ 0.00080, 0.00085, 0.00090, 0.00095, 0.00100, 0.00105, 0.00110, 0.00115,
+ 0.00120, 0.00125, 0.00130, 0.00135, 0.00140, 0.00145, 0.00150, 0.00155,
+ 0.00160, 0.00165, 0.00170, 0.00175, 0.00180, 0.00185, 0.00190, 0.00195,
+ 0.00200, 0.00205, 0.00210, 0.00215, 0.00220, 0.00225, 0.00230, 0.00236,
+ 0.00241, 0.00246, 0.00251, 0.00256, 0.00261, 0.00266, 0.00272, 0.00277,
+ 0.00282, 0.00287, 0.00293, 0.00298, 0.00304, 0.00309, 0.00314, 0.00320,
+ 0.00326, 0.00331, 0.00337, 0.00343, 0.00349, 0.00354, 0.00361, 0.00367,
+ 0.00373, 0.00379, 0.00386, 0.00393, 0.00400, 0.00407, 0.00414, 0.00422,
+ 0.00430, 0.00438, 0.00447, 0.00456, 0.00465, 0.00475, 0.00485, 0.00496,
+ 0.00508, 0.00520, 0.00533, 0.00547, 0.00562, 0.00578, 0.00595, 0.00614,
+ 0.00633, 0.00655, 0.00678, 0.00703, 0.00730, 0.00760, 0.00792, 0.00827,
+ 0.00865, 0.00907, 0.00952, 0.01002, 0.01057, 0.01117, 0.01182, 0.01254,
+ 0.01334, 0.01420, 0.01516, 0.01621, 0.01736, 0.01863, 0.02002, 0.02156,
+ 0.02324, 0.02510, 0.02714, 0.02938, 0.03185, 0.03455, 0.03753, 0.04080,
+ 0.04438, 0.04832, 0.05263, 0.05735, 0.06252, 0.06817, 0.07434, 0.08108,
+ 0.08841, 0.09639, 0.10506, 0.11447, 0.12464, 0.13564, 0.14749, 0.16024,
+ 0.17392, 0.18855, 0.20416, 0.22076, 0.23835, 0.25693, 0.27649, 0.29698,
+ 0.31838, 0.34062, 0.36363, 0.38733, 0.41164, 0.43643, 0.46160, 0.48702,
+ 0.51257, 0.53811, 0.56352, 0.58866, 0.61342, 0.63768, 0.66134, 0.68429,
+ 0.70646, 0.72779, 0.74821, 0.76769, 0.78619, 0.80370, 0.82022, 0.83575,
+ 0.85031, 0.86391, 0.87658, 0.88837, 0.89930, 0.90941, 0.91875, 0.92737,
+ 0.93530, 0.94258, 0.94927, 0.95540, 0.96101, 0.96614, 0.97083, 0.97511,
+ 0.97901, 0.98257, 0.98582, 0.98877, 0.99146, 0.99390, 0.99613, 0.99815,
+ 1.00000
+ ]
+ ContinuousQuestionExample2:
+ summary: Continuous Question - open lower bound, closed upper bound
+ value:
+ - question: 1
+ continuous_cdf: [
+ 0.00655, 0.00690, 0.00727, 0.00766, 0.00807, 0.00849, 0.00894, 0.00941,
+ 0.00990, 0.01042, 0.01096, 0.01152, 0.01212, 0.01274, 0.01340, 0.01409,
+ 0.01481, 0.01557, 0.01636, 0.01720, 0.01808, 0.01900, 0.01997, 0.02099,
+ 0.02206, 0.02318, 0.02436, 0.02560, 0.02690, 0.02827, 0.02970, 0.03121,
+ 0.03280, 0.03446, 0.03621, 0.03804, 0.03997, 0.04199, 0.04411, 0.04634,
+ 0.04868, 0.05113, 0.05370, 0.05639, 0.05922, 0.06218, 0.06528, 0.06853,
+ 0.07193, 0.07550, 0.07923, 0.08313, 0.08721, 0.09147, 0.09593, 0.10058,
+ 0.10544, 0.11051, 0.11580, 0.12131, 0.12705, 0.13303, 0.13925, 0.14572,
+ 0.15244, 0.15942, 0.16667, 0.17418, 0.18197, 0.19003, 0.19837, 0.20699,
+ 0.21589, 0.22508, 0.23454, 0.24429, 0.25432, 0.26463, 0.27521, 0.28606,
+ 0.29717, 0.30854, 0.32015, 0.33201, 0.34410, 0.35640, 0.36891, 0.38162,
+ 0.39450, 0.40755, 0.42075, 0.43408, 0.44752, 0.46106, 0.47468, 0.48836,
+ 0.50207, 0.51581, 0.52954, 0.54325, 0.55692, 0.57052, 0.58404, 0.59745,
+ 0.61073, 0.62386, 0.63683, 0.64962, 0.66221, 0.67458, 0.68673, 0.69863,
+ 0.71029, 0.72168, 0.73280, 0.74364, 0.75420, 0.76447, 0.77444, 0.78412,
+ 0.79350, 0.80258, 0.81136, 0.81984, 0.82802, 0.83592, 0.84352, 0.85084,
+ 0.85788, 0.86464, 0.87114, 0.87737, 0.88334, 0.88907, 0.89455, 0.89979,
+ 0.90481, 0.90961, 0.91419, 0.91856, 0.92273, 0.92671, 0.93051, 0.93413,
+ 0.93758, 0.94086, 0.94399, 0.94697, 0.94980, 0.95249, 0.95505, 0.95749,
+ 0.95980, 0.96200, 0.96410, 0.96608, 0.96797, 0.96976, 0.97146, 0.97308,
+ 0.97461, 0.97607, 0.97745, 0.97877, 0.98001, 0.98120, 0.98232, 0.98339,
+ 0.98440, 0.98536, 0.98627, 0.98713, 0.98796, 0.98874, 0.98948, 0.99018,
+ 0.99085, 0.99149, 0.99209, 0.99266, 0.99321, 0.99373, 0.99422, 0.99469,
+ 0.99513, 0.99556, 0.99596, 0.99635, 0.99671, 0.99706, 0.99739, 0.99771,
+ 0.99801, 0.99830, 0.99858, 0.99884, 0.99909, 0.99933, 0.99956, 0.99978,
+ 1.00000
+ ]
+ MultipleChoiceQuestionExample:
+ summary: Multiple Choice
+ value:
+ - question: 1
+ probability_yes_per_category:
+ {
+ "Futurama": 0.5,
+ "Paperclipalypse": 0.3,
+ "Singularia": 0.2
+ }
+ ConditionalQuestionExample:
+ summary: Conditional Question
+ value:
+ [
+ # Forecast for question "if Yes"
+ {
+ "question": 1,
+ "probability_yes": 0.499,
+ },
+ # Forecast for question "if No"
+ {
+ "question": 2,
+ "probability_yes": 0.501,
+ }
+ ]
+ BinaryGroupExample:
+ summary: Binary group of questions forecast example
+ value:
+ [
+ { "question": 1, "probability_yes": 0.11 },
+ { "question": 2, "probability_yes": 0.22 },
+ { "question": 3, "probability_yes": 0.33 }
+ ]
+ responses:
+ '201':
+ description: Forecasts submitted successfully
+ '400':
+ description: Invalid request format
+ /questions/withdraw/:
+ post:
+ operationId: WithdrawForecast
+ summary: Withdraw current forecasts for questions
+ description: This endpoint supports multiple simultaneous withdrawals, so the base object is a list of withdrawals.
+ Pass one withdrawal object for single questions and multiple withdrawal objects for group of questions or conditional forecasts.
+ tags:
+ - Questions & Forecasts
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: array
+ items:
+ $ref: '#/components/schemas/Withdrawal'
+ /comments/create/:
+ post:
+ summary: Create a new comment
+ description: Submit a new comment on a post
+ tags:
+ - Comments
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required:
+ - included_forecast
+ - is_private
+ - on_post
+ - text
+ properties:
+ included_forecast:
+ type: boolean
+ description: Include the user's last forecast.
+ is_private:
+ type: boolean
+ description: If the comment is private or public.
+ on_post:
+ type: integer
+ description: ID of the post.
+ parent:
+ type: integer
+ description: ID of the comment you're replying to.
+ text:
+ type: string
+ description: The content of the comment.
+ examples:
+ CreateCommentExample:
+ summary: Example of a comment creation
+ value:
+ included_forecast: false
+ is_private: false
+ on_post: 1
+ text: "Bot comment"
+ responses:
+ '201':
+ description: Comment created successfully
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Comment'
+ '400':
+ description: Invalid request format
+ /comments/:
+ get:
+ summary: Retrieve comments
+ description: Fetch comments with optional filters for post, author, pagination, and sorting. Either the `post` or `author` parameter is required.
+ tags:
+ - Comments
+ parameters:
+ - name: post
+ in: query
+ required: false
+ schema:
+ type: integer
+ description: ID of the post to filter comments by.
+ - name: author
+ in: query
+ required: false
+ schema:
+ type: integer
+ description: ID of the author to filter comments by.
+ - name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ description: Number of comments to retrieve.
+ - name: offset
+ in: query
+ required: false
+ schema:
+ type: integer
+ description: Offset for pagination.
+ - name: is_private
+ in: query
+ required: false
+ schema:
+ type: boolean
+ default: false
+ description: Filter between private comments (for the current user) and public comments. Defaults to false.
+ - name: use_root_comments_pagination
+ in: query
+ required: false
+ schema:
+ type: boolean
+ description: If true, pagination will only apply to root comments, and all child comments will be included for those root comments.
+ - name: sort
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [ "-created_at", "created_at" ]
+ description: Sort comments by creation date. Use `-created_at` for descending order.
+ - name: focus_comment_id
+ in: query
+ required: false
+ schema:
+ type: integer
+ description: The ID of a comment to place at the top of the results.
+ responses:
+ '200':
+ description: List of comments
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ total_count:
+ type: integer
+ description: Total number of root and child comments.
+ count:
+ type: integer
+ description: Total number of root comments only.
+ next:
+ type: string
+ format: uri
+ nullable: true
+ description: URL for the next page of results, if available.
+ previous:
+ type: string
+ format: uri
+ nullable: true
+ description: URL for the previous page of results, if available.
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/Comment'
+ '400':
+ description: Invalid request format
\ No newline at end of file
diff --git a/core/src/BaseExchange.ts b/core/src/BaseExchange.ts
index 14bc9a3..65ed710 100644
--- a/core/src/BaseExchange.ts
+++ b/core/src/BaseExchange.ts
@@ -200,6 +200,8 @@ export interface ExchangeCredentials {
apiKey?: string;
apiSecret?: string;
passphrase?: string;
+ /** Metaculus: `Authorization: Token ` for higher rate limits */
+ apiToken?: string;
// Blockchain-based authentication (Polymarket)
privateKey?: string; // Required for Polymarket L1 auth
diff --git a/core/src/exchanges/metaculus/api.ts b/core/src/exchanges/metaculus/api.ts
new file mode 100644
index 0000000..0d32f8d
--- /dev/null
+++ b/core/src/exchanges/metaculus/api.ts
@@ -0,0 +1,415 @@
+/**
+ * Auto-generated from /home/harry-riddle/dev/github.com/0xharryriddle/pmxt/core/specs/metaculus/Metaculus.yaml
+ * Generated at: 2026-03-01T14:46:24.859Z
+ * Do not edit manually -- run "npm run fetch:openapi" to regenerate.
+ */
+export const metaculusApiSpec = {
+ "openapi": "3.0.0",
+ "info": {
+ "version": "2.0.0",
+ "title": "Metaculus API"
+ },
+ "servers": [
+ {
+ "url": "https://www.metaculus.com/api"
+ }
+ ],
+ "security": [
+ {
+ "TokenAuth": []
+ }
+ ],
+ "components": {
+ "securitySchemes": {
+ "TokenAuth": {
+ "type": "apiKey",
+ "in": "header",
+ "name": "Authorization",
+ "description": "Token-based authentication. Use format: `Token `"
+ }
+ }
+ },
+ "tags": [
+ {
+ "name": "Feed"
+ },
+ {
+ "name": "Questions & Forecasts"
+ },
+ {
+ "name": "Comments"
+ },
+ {
+ "name": "Utilities & Data"
+ }
+ ],
+ "paths": {
+ "/posts/": {
+ "get": {
+ "operationId": "GetPosts",
+ "summary": "Retrieve posts feed",
+ "tags": [
+ "Feed"
+ ],
+ "parameters": [
+ {
+ "in": "query",
+ "name": "tournaments",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "in": "query",
+ "name": "statuses",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "upcoming",
+ "closed",
+ "resolved",
+ "open"
+ ]
+ }
+ }
+ },
+ {
+ "in": "query",
+ "name": "forecaster_id",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "with_cp",
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "in": "query",
+ "name": "not_forecaster_id",
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "in": "query",
+ "name": "open_time__gt",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ {
+ "in": "query",
+ "name": "published_at__gt",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ {
+ "in": "query",
+ "name": "scheduled_resolve_time__gt",
+ "schema": {
+ "type": "string",
+ "format": "date-time"
+ }
+ },
+ {
+ "in": "query",
+ "name": "forecast_type",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "binary",
+ "numeric",
+ "date",
+ "multiple_choice",
+ "conditional",
+ "group_of_questions",
+ "notebook"
+ ]
+ }
+ }
+ },
+ {
+ "in": "query",
+ "name": "order_by",
+ "schema": {
+ "type": "string",
+ "enum": [
+ "published_at",
+ "open_time",
+ "vote_score",
+ "comment_count",
+ "forecasts_count",
+ "scheduled_close_time",
+ "scheduled_resolve_time",
+ "user_last_forecasts_date",
+ "unread_comment_count",
+ "weekly_movement",
+ "divergence",
+ "hotness",
+ "score"
+ ]
+ }
+ }
+ ]
+ }
+ },
+ "/posts/{postId}/": {
+ "get": {
+ "operationId": "GetPost",
+ "summary": "Retrieve post details",
+ "tags": [
+ "Feed"
+ ],
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ]
+ }
+ },
+ "/posts/{postId}/download-data/": {
+ "get": {
+ "summary": "Download data for a Question. Will open a download prompt in the browser. The return is a Zip file of CSVs.",
+ "tags": [
+ "Utilities & Data"
+ ],
+ "parameters": [
+ {
+ "name": "postId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "sub_question",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "aggregation_methods",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string",
+ "enum": [
+ "recency_weighted",
+ "unweighted",
+ "metaculus_prediction",
+ "single_aggregation"
+ ]
+ }
+ }
+ },
+ {
+ "name": "include_bots",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "name": "user_ids",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "name": "minimize",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "name": "include_comments",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "name": "include_scores",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ ]
+ }
+ },
+ "/projects/{projectId}/download-data/": {
+ "get": {
+ "summary": "Download data for a whole Project. Will open a download prompt in the browser. The return is a Zip file of CSVs. Only available to site admins and Whitelisted users.",
+ "tags": [
+ "Utilities & Data"
+ ],
+ "parameters": [
+ {
+ "name": "projectId",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "include_comments",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "name": "include_scores",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ }
+ ]
+ }
+ },
+ "/questions/forecast/": {
+ "post": {
+ "operationId": "SubmitForecast",
+ "summary": "Submit forecasts for questions",
+ "tags": [
+ "Questions & Forecasts"
+ ]
+ }
+ },
+ "/questions/withdraw/": {
+ "post": {
+ "operationId": "WithdrawForecast",
+ "summary": "Withdraw current forecasts for questions",
+ "tags": [
+ "Questions & Forecasts"
+ ]
+ }
+ },
+ "/comments/create/": {
+ "post": {
+ "summary": "Create a new comment",
+ "tags": [
+ "Comments"
+ ]
+ }
+ },
+ "/comments/": {
+ "get": {
+ "summary": "Retrieve comments",
+ "tags": [
+ "Comments"
+ ],
+ "parameters": [
+ {
+ "name": "post",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "author",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "offset",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "is_private",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ {
+ "name": "use_root_comments_pagination",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "boolean"
+ }
+ },
+ {
+ "name": "sort",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "-created_at",
+ "created_at"
+ ]
+ }
+ },
+ {
+ "name": "focus_comment_id",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ }
+ ]
+ }
+ }
+ }
+};
diff --git a/core/src/exchanges/metaculus/cancelOrder.ts b/core/src/exchanges/metaculus/cancelOrder.ts
new file mode 100644
index 0000000..66c236a
--- /dev/null
+++ b/core/src/exchanges/metaculus/cancelOrder.ts
@@ -0,0 +1,94 @@
+import { AxiosInstance } from "axios";
+import { Order } from "../../types";
+import { AuthenticationError, ValidationError } from "../../errors";
+import { metaculusErrorMapper } from "./errors";
+import { BASE_URL } from "./utils";
+
+/**
+ * Parameters for the internal cancelOrder function.
+ */
+export interface CancelOrderContext {
+ /** The exchange's axios instance (with rate limiting and logging). */
+ http: AxiosInstance;
+ /** Returns auth headers. Throws if no token is configured. */
+ getAuthHeaders: () => Record;
+}
+
+/**
+ * Withdraw a forecast on Metaculus, mapped from the unified cancelOrder interface.
+ *
+ * ## How the mapping works
+ *
+ * "Cancelling an order" on Metaculus means withdrawing your forecast from a
+ * question. After withdrawal, your prediction no longer affects the community
+ * aggregate and is not scored.
+ *
+ * The `orderId` parameter should be the **Metaculus question ID** (the numeric
+ * ID used in the forecast API, not the post ID). If you created the forecast
+ * via `createOrder`, the question ID is encoded in the returned order's
+ * `outcomeId` (the part before the hyphen).
+ *
+ * ## Authentication
+ *
+ * Requires a Metaculus API token. Pass `{ apiToken: "..." }` when constructing
+ * the MetaculusExchange.
+ *
+ * @param orderId The Metaculus question ID to withdraw the forecast from.
+ * @param ctx HTTP client and auth context.
+ * @returns A synthetic Order with status "cancelled".
+ *
+ * @throws {AuthenticationError} If no API token is configured.
+ * @throws {ValidationError} If the orderId is not a valid numeric question ID.
+ */
+export async function cancelOrder(
+ orderId: string,
+ ctx: CancelOrderContext,
+): Promise {
+ try {
+ // 1. Validate auth
+ const headers = ctx.getAuthHeaders();
+ if (!headers.Authorization) {
+ throw new AuthenticationError(
+ 'Metaculus forecast withdrawal requires authentication. '
+ + 'Pass { apiToken: "..." } when constructing MetaculusExchange.',
+ "Metaculus",
+ );
+ }
+
+ // 2. Parse question ID
+ const questionId = parseInt(orderId, 10);
+ if (isNaN(questionId)) {
+ throw new ValidationError(
+ `Invalid orderId "${orderId}": expected a numeric Metaculus question ID. `
+ + "Use the question ID from the outcomeId (the part before the hyphen).",
+ "Metaculus",
+ );
+ }
+
+ // 3. POST directly to the withdraw endpoint.
+ // Bypasses callApi because the API expects an array body.
+ await ctx.http.request({
+ method: "POST",
+ url: `${BASE_URL}/questions/withdraw/`,
+ data: [{ question: questionId }],
+ headers: { "Content-Type": "application/json", ...headers },
+ });
+
+ // 4. Return synthetic cancelled order
+ return {
+ id: `mc-withdraw-${questionId}-${Date.now()}`,
+ marketId: orderId,
+ outcomeId: orderId,
+ side: "buy",
+ type: "market",
+ amount: 1,
+ status: "cancelled",
+ filled: 0,
+ remaining: 0,
+ timestamp: Date.now(),
+ };
+ } catch (error: any) {
+ if (error.statusCode) throw error;
+ throw metaculusErrorMapper.mapError(error);
+ }
+}
diff --git a/core/src/exchanges/metaculus/createOrder.ts b/core/src/exchanges/metaculus/createOrder.ts
new file mode 100644
index 0000000..c6b186a
--- /dev/null
+++ b/core/src/exchanges/metaculus/createOrder.ts
@@ -0,0 +1,407 @@
+import { AxiosInstance } from "axios";
+import { CreateOrderParams, Order, MarketOutcome } from "../../types";
+import { AuthenticationError, InvalidOrder, ValidationError } from "../../errors";
+import { metaculusErrorMapper } from "./errors";
+import { BASE_URL } from "./utils";
+
+// ---------------------------------------------------------------------------
+// OutcomeId Parsing
+// ---------------------------------------------------------------------------
+
+/**
+ * Parsed result from a Metaculus outcomeId string.
+ *
+ * OutcomeId format:
+ * - Binary: `-YES` or `-NO`
+ * - Multiple-choice: `-` (numeric index)
+ * - Continuous: `-HIGHER` or `-LOWER` (not tradeable)
+ */
+export interface ParsedOutcomeId {
+ /** The Metaculus question ID (used in the forecast API). */
+ questionId: number;
+ /** The question type inferred from the suffix. */
+ type: "binary" | "multiple_choice" | "continuous";
+ /** The raw suffix after the first hyphen (YES, NO, HIGHER, LOWER, or index). */
+ suffix: string;
+ /** For multiple-choice outcomes, the 0-based category index. */
+ categoryIndex?: number;
+}
+
+/**
+ * Parse a Metaculus outcomeId into its components.
+ *
+ * @throws {ValidationError} If the outcomeId format is unrecognizable.
+ */
+export function parseOutcomeId(outcomeId: string): ParsedOutcomeId {
+ const dashIdx = outcomeId.indexOf("-");
+ if (dashIdx === -1) {
+ throw new ValidationError(
+ `Invalid Metaculus outcomeId "${outcomeId}". `
+ + 'Expected format: "-YES", "-NO", or "-".',
+ "Metaculus",
+ );
+ }
+
+ const idPart = outcomeId.slice(0, dashIdx);
+ const suffix = outcomeId.slice(dashIdx + 1);
+ const questionId = parseInt(idPart, 10);
+
+ if (isNaN(questionId)) {
+ throw new ValidationError(
+ `Invalid question ID in outcomeId "${outcomeId}". The part before the hyphen must be a numeric question ID.`,
+ "Metaculus",
+ );
+ }
+
+ const upperSuffix = suffix.toUpperCase();
+
+ if (upperSuffix === "HIGHER" || upperSuffix === "LOWER") {
+ return { questionId, type: "continuous", suffix };
+ }
+
+ if (upperSuffix === "YES" || upperSuffix === "NO") {
+ return { questionId, type: "binary", suffix };
+ }
+
+ // Numeric suffix -> multiple-choice category index
+ const idx = parseInt(suffix, 10);
+ if (!isNaN(idx) && idx >= 0) {
+ return { questionId, type: "multiple_choice", suffix, categoryIndex: idx };
+ }
+
+ throw new ValidationError(
+ `Unrecognized outcomeId suffix "${suffix}" in "${outcomeId}". `
+ + 'Expected YES, NO, HIGHER, LOWER, or a numeric category index.',
+ "Metaculus",
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Probability Validation
+// ---------------------------------------------------------------------------
+
+/**
+ * Validate that a probability value is in the open interval (0, 1).
+ *
+ * Metaculus requires probability_yes to be strictly between 0 and 1.
+ * The exact boundaries (0.0 and 1.0) are rejected by the API.
+ *
+ * @throws {InvalidOrder} If the value is missing or out of range.
+ */
+export function validateProbability(price: number | undefined): number {
+ if (price === undefined || price === null) {
+ throw new InvalidOrder(
+ "Metaculus createOrder requires `price` (the probability to forecast, between 0 and 1 exclusive).",
+ "Metaculus",
+ );
+ }
+ if (typeof price !== "number" || isNaN(price)) {
+ throw new InvalidOrder(
+ `Invalid price "${price}": must be a number between 0 and 1 exclusive.`,
+ "Metaculus",
+ );
+ }
+ if (price <= 0 || price >= 1) {
+ throw new InvalidOrder(
+ `Probability ${price} is out of range. Metaculus requires a value strictly between 0 and 1 (e.g., 0.01 to 0.99).`,
+ "Metaculus",
+ );
+ }
+ return price;
+}
+
+// ---------------------------------------------------------------------------
+// Multiple-Choice Redistribution
+// ---------------------------------------------------------------------------
+
+/**
+ * Redistribute multiple-choice probabilities when setting one category.
+ *
+ * When a user sets category X to probability P, the remaining categories
+ * must be adjusted so all probabilities sum to 1.0. This function scales
+ * the non-target categories proportionally.
+ *
+ * @param currentProbabilities Map of category label -> current probability.
+ * @param targetLabel The category label being set.
+ * @param targetProbability The new probability for the target category.
+ * @returns A new map with all probabilities summing to 1.0.
+ *
+ * @throws {InvalidOrder} If the target category doesn't exist or redistribution
+ * is impossible (e.g., target = 1.0 with other categories).
+ */
+export function redistributeProbabilities(
+ currentProbabilities: Record,
+ targetLabel: string,
+ targetProbability: number,
+): Record {
+ const labels = Object.keys(currentProbabilities);
+
+ if (!labels.includes(targetLabel)) {
+ throw new InvalidOrder(
+ `Category "${targetLabel}" not found. Available categories: ${labels.join(", ")}`,
+ "Metaculus",
+ );
+ }
+
+ if (labels.length < 2) {
+ return { [targetLabel]: 1.0 };
+ }
+
+ const remaining = 1.0 - targetProbability;
+ if (remaining <= 0) {
+ throw new InvalidOrder(
+ `Cannot set probability to ${targetProbability}: other categories would have zero or negative probability. `
+ + "Use a value less than 1.0.",
+ "Metaculus",
+ );
+ }
+
+ // Sum of current probabilities for non-target categories
+ const otherSum = labels.reduce((sum, label) => {
+ return label === targetLabel ? sum : sum + (currentProbabilities[label] ?? 0);
+ }, 0);
+
+ const result: Record = {};
+
+ if (otherSum <= 0) {
+ // All other categories are at zero -- distribute evenly
+ const otherCount = labels.length - 1;
+ const each = remaining / otherCount;
+ for (const label of labels) {
+ result[label] = label === targetLabel ? targetProbability : each;
+ }
+ } else {
+ // Proportional redistribution
+ const scale = remaining / otherSum;
+ for (const label of labels) {
+ result[label] = label === targetLabel
+ ? targetProbability
+ : (currentProbabilities[label] ?? 0) * scale;
+ }
+ }
+
+ // Normalize to fix floating-point drift: adjust the largest non-target category
+ const sum = Object.values(result).reduce((a, b) => a + b, 0);
+ const drift = sum - 1.0;
+ if (Math.abs(drift) > 1e-12) {
+ const largest = labels
+ .filter((l) => l !== targetLabel)
+ .sort((a, b) => result[b] - result[a])[0];
+ if (largest) {
+ result[largest] -= drift;
+ }
+ }
+
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Synthetic Order Builder
+// ---------------------------------------------------------------------------
+
+/**
+ * Build a synthetic Order from forecast parameters.
+ *
+ * Metaculus forecasts are instant (no pending/open state), so the returned
+ * order always has status "filled". The order ID is a generated string
+ * since Metaculus doesn't return order IDs.
+ */
+function buildSyntheticOrder(
+ params: CreateOrderParams,
+ status: "filled" | "cancelled",
+): Order {
+ return {
+ id: `mc-${params.marketId}-${Date.now()}`,
+ marketId: params.marketId,
+ outcomeId: params.outcomeId,
+ side: "buy",
+ type: "market",
+ price: params.price,
+ amount: 1,
+ status,
+ filled: status === "filled" ? 1 : 0,
+ remaining: 0,
+ timestamp: Date.now(),
+ };
+}
+
+// ---------------------------------------------------------------------------
+// createOrder
+// ---------------------------------------------------------------------------
+
+/**
+ * Parameters for the internal createOrder function.
+ *
+ * Accepts the exchange's HTTP client and sign function so the trading
+ * module doesn't need access to the full exchange instance.
+ */
+export interface CreateOrderContext {
+ /** The exchange's axios instance (with rate limiting and logging). */
+ http: AxiosInstance;
+ /** Returns auth headers. Throws if no token is configured. */
+ getAuthHeaders: () => Record;
+ /**
+ * Fetch current market outcomes to read multiple-choice probabilities.
+ * Only needed for multiple-choice questions.
+ */
+ fetchOutcomes?: (marketId: string) => Promise;
+}
+
+/**
+ * Submit a forecast on Metaculus, mapped from the unified createOrder interface.
+ *
+ * ## How the mapping works
+ *
+ * Metaculus is a reputation-based forecasting platform, not a financial exchange.
+ * "Creating an order" means submitting a probability forecast on a question.
+ *
+ * | CreateOrderParams field | Metaculus meaning |
+ * |------------------------|-------------------|
+ * | `marketId` | Post ID (for reference only) |
+ * | `outcomeId` | Encodes the question ID + type (see {@link parseOutcomeId}) |
+ * | `price` | The probability to forecast (0-1 exclusive) |
+ * | `side` | Ignored -- forecasts are always "buy" (you submit a belief) |
+ * | `type` | Ignored -- forecasts execute instantly (always "market") |
+ * | `amount` | Ignored -- Metaculus has no stake size |
+ *
+ * The returned {@link Order} is synthetic: Metaculus doesn't return order IDs
+ * or track fill state. The order is always immediately "filled".
+ *
+ * ## Supported question types
+ *
+ * - **Binary**: Sets `probability_yes` directly from `price`.
+ * - **Multiple-choice**: Sets the target category's probability and
+ * redistributes others proportionally to sum to 1.0.
+ * - **Continuous/numeric/date**: NOT supported -- throws {@link InvalidOrder}
+ * because a 201-point CDF cannot be expressed as a single price value.
+ *
+ * ## Authentication
+ *
+ * Requires a Metaculus API token. Pass `{ apiToken: "..." }` when constructing
+ * the MetaculusExchange. Without a token, this method throws
+ * {@link AuthenticationError}.
+ *
+ * @throws {AuthenticationError} If no API token is configured.
+ * @throws {InvalidOrder} If the question type is continuous or the price is invalid.
+ * @throws {ValidationError} If the outcomeId format is unrecognizable.
+ */
+export async function createOrder(
+ params: CreateOrderParams,
+ ctx: CreateOrderContext,
+): Promise {
+ try {
+ // 1. Validate auth
+ const headers = ctx.getAuthHeaders();
+ if (!headers.Authorization) {
+ throw new AuthenticationError(
+ 'Metaculus forecast submission requires authentication. '
+ + 'Pass { apiToken: "..." } when constructing MetaculusExchange.',
+ "Metaculus",
+ );
+ }
+
+ // 2. Parse outcomeId to determine question type
+ const parsed = parseOutcomeId(params.outcomeId);
+
+ // 3. Continuous questions can't be traded via createOrder
+ if (parsed.type === "continuous") {
+ throw new InvalidOrder(
+ "Continuous/numeric/date questions cannot be traded via createOrder. "
+ + "These require a 201-point CDF which cannot be expressed as a single price. "
+ + "Use the Metaculus API directly for continuous forecasts.",
+ "Metaculus",
+ );
+ }
+
+ // 4. Validate price
+ const probability = validateProbability(params.price);
+
+ // 5. Log warnings for params that don't apply to Metaculus
+ if (params.side && params.side !== "buy") {
+ console.warn(
+ `[pmxt/Metaculus] Ignoring side="${params.side}" -- Metaculus forecasts are probability submissions, not buy/sell. `
+ + "Set the probability via the `price` parameter instead.",
+ );
+ }
+ if (params.type && params.type !== "market") {
+ console.warn(
+ `[pmxt/Metaculus] Ignoring type="${params.type}" -- Metaculus forecasts execute instantly (no limit orders).`,
+ );
+ }
+
+ // 6. Build the forecast payload
+ let payload: any[];
+
+ if (parsed.type === "binary") {
+ payload = [{ question: parsed.questionId, probability_yes: probability }];
+ } else {
+ // Multiple-choice: need current probabilities to redistribute
+ if (!ctx.fetchOutcomes) {
+ throw new InvalidOrder(
+ "Multiple-choice forecast requires market outcome data but fetchOutcomes is not available.",
+ "Metaculus",
+ );
+ }
+
+ const outcomes = await ctx.fetchOutcomes(params.marketId);
+ const mcOutcomes = outcomes.filter(
+ (o) => o.metadata?.question_type === "multiple_choice"
+ && o.metadata?.question_id === parsed.questionId,
+ );
+
+ if (mcOutcomes.length === 0) {
+ throw new InvalidOrder(
+ `No multiple-choice outcomes found for question ${parsed.questionId}. `
+ + "Ensure the market has been fetched and the outcomeId is correct.",
+ "Metaculus",
+ );
+ }
+
+ // Build current probability map from outcome labels
+ const currentProbs: Record = {};
+ for (const o of mcOutcomes) {
+ currentProbs[o.label] = o.price;
+ }
+
+ // Find the target category by index
+ const targetOutcome = mcOutcomes.find(
+ (o) => o.metadata?.choice_index === parsed.categoryIndex,
+ );
+ if (!targetOutcome) {
+ throw new InvalidOrder(
+ `Category index ${parsed.categoryIndex} not found for question ${parsed.questionId}. `
+ + `Available indices: 0-${mcOutcomes.length - 1}.`,
+ "Metaculus",
+ );
+ }
+
+ const redistributed = redistributeProbabilities(
+ currentProbs,
+ targetOutcome.label,
+ probability,
+ );
+
+ payload = [{
+ question: parsed.questionId,
+ probability_yes_per_category: redistributed,
+ }];
+ }
+
+ // 7. POST directly to the forecast endpoint.
+ // We bypass callApi because the Metaculus forecast API expects an
+ // array body, but the implicit API infrastructure always sends objects.
+ await ctx.http.request({
+ method: "POST",
+ url: `${BASE_URL}/questions/forecast/`,
+ data: payload,
+ headers: { "Content-Type": "application/json", ...headers },
+ });
+
+ // 8. Return synthetic order
+ return buildSyntheticOrder(params, "filled");
+ } catch (error: any) {
+ // Re-throw pmxt errors directly; map everything else
+ if (error.statusCode) throw error;
+ throw metaculusErrorMapper.mapError(error);
+ }
+}
diff --git a/core/src/exchanges/metaculus/errors.ts b/core/src/exchanges/metaculus/errors.ts
new file mode 100644
index 0000000..f2143ea
--- /dev/null
+++ b/core/src/exchanges/metaculus/errors.ts
@@ -0,0 +1,89 @@
+import { ErrorMapper } from '../../utils/error-mapper';
+import {
+ NotFound,
+ MarketNotFound,
+ AuthenticationError,
+ PermissionDenied,
+ BadRequest,
+ InvalidOrder,
+ BaseError,
+} from '../../errors';
+
+/**
+ * Metaculus-specific error mapper.
+ *
+ * Extends the base error mapper with:
+ * - 404: question/market not-found detection
+ * - 401: actionable message pointing users to pass { apiToken }
+ * - 403: distinguishes missing auth (-> AuthenticationError) from insufficient permissions
+ * - 400: probability validation errors from the forecast API
+ */
+export class MetaculusErrorMapper extends ErrorMapper {
+ constructor() {
+ super('Metaculus');
+ }
+
+ protected override mapNotFoundError(message: string, _data: any): NotFound {
+ const lower = message.toLowerCase();
+ if (lower.includes('question') || lower.includes('market')) {
+ const match = message.match(/[\d]+/);
+ const id = match ? match[0] : 'unknown';
+ return new MarketNotFound(id, this.exchangeName);
+ }
+ return new NotFound(message, this.exchangeName);
+ }
+
+ protected override mapBadRequestError(message: string, data: any): BadRequest {
+ const lower = message.toLowerCase();
+
+ // Probability validation errors from the forecast API
+ if (
+ lower.includes('probability') ||
+ lower.includes('continuous_cdf') ||
+ lower.includes('forecast')
+ ) {
+ return new InvalidOrder(
+ `Metaculus forecast rejected: ${message}`,
+ this.exchangeName,
+ );
+ }
+
+ return super.mapBadRequestError(message, data);
+ }
+
+ /**
+ * Override the top-level mapByStatusCode for Metaculus-specific auth messages.
+ */
+ protected override mapByStatusCode(
+ status: number,
+ message: string,
+ data: any,
+ response?: any,
+ ): BaseError {
+ if (status === 401) {
+ return new AuthenticationError(
+ 'Metaculus API token required. Pass { apiToken: "..." } when constructing MetaculusExchange.',
+ this.exchangeName,
+ );
+ }
+ if (status === 403) {
+ const lower = message.toLowerCase();
+ // Metaculus returns 403 both for missing auth and insufficient permissions.
+ // Distinguish by checking if the message mentions authentication.
+ if (lower.includes('authenticated') || lower.includes('api token') || lower.includes('log in')) {
+ return new AuthenticationError(
+ 'Metaculus API token required. Pass { apiToken: "..." } when constructing MetaculusExchange.',
+ this.exchangeName,
+ );
+ }
+ return new PermissionDenied(
+ 'You do not have permission for this operation. '
+ + 'Check your Metaculus account permissions and API token scope.',
+ this.exchangeName,
+ );
+ }
+ return super.mapByStatusCode(status, message, data, response);
+ }
+}
+
+export const metaculusErrorMapper = new MetaculusErrorMapper();
diff --git a/core/src/exchanges/metaculus/fetchEvents.ts b/core/src/exchanges/metaculus/fetchEvents.ts
new file mode 100644
index 0000000..b216df5
--- /dev/null
+++ b/core/src/exchanges/metaculus/fetchEvents.ts
@@ -0,0 +1,231 @@
+import { EventFetchParams } from "../../BaseExchange";
+import { UnifiedEvent } from "../../types";
+import { expandPost } from "./utils";
+import { metaculusErrorMapper } from "./errors";
+
+type CallApi = (
+ operationId: string,
+ params?: Record,
+) => Promise;
+
+const BATCH_SIZE = 100;
+const MAX_PAGES = 200;
+
+/**
+ * Map pmxt status values to Metaculus `statuses` array param.
+ */
+function toApiStatuses(status?: string): string[] | undefined {
+ if (!status || status === "all") return undefined;
+ if (status === "closed" || status === "inactive") return ["closed", "resolved"];
+ return ["open"];
+}
+
+/**
+ * Fetch pages of posts with pagination.
+ */
+async function fetchPostPages(
+ callApi: CallApi,
+ apiParams: Record,
+ targetCount?: number,
+): Promise {
+ let all: any[] = [];
+ let offset = 0;
+ let page = 0;
+
+ do {
+ const data = await callApi("GetPosts", {
+ ...apiParams,
+ limit: BATCH_SIZE,
+ offset,
+ });
+
+ const results: any[] = data.results ?? [];
+ if (results.length === 0) break;
+
+ all = all.concat(results);
+ offset += results.length;
+ page++;
+
+ if (targetCount && all.length >= targetCount) break;
+ if (!data.next) break;
+ } while (page < MAX_PAGES);
+
+ return all;
+}
+
+/**
+ * Wrap a single Metaculus Post as a UnifiedEvent.
+ *
+ * For single-question posts, the event contains one market.
+ * For group-of-questions posts, the event contains one market per sub-question
+ * (expanded via expandPost).
+ */
+function postToEvent(post: any): UnifiedEvent | null {
+ const markets = expandPost(post);
+ if (markets.length === 0) return null;
+
+ const id = String(post.id);
+ return {
+ id,
+ title: post.title ?? "",
+ description: post.question?.description
+ ?? post.group_of_questions?.description
+ ?? post.question?.resolution_criteria
+ ?? "",
+ slug: post.slug ?? post.url_title ?? id,
+ markets,
+ volume24h: 0,
+ volume: 0,
+ url: `https://www.metaculus.com/questions/${id}/`,
+ image: post.projects?.default_project?.header_image ?? undefined,
+ category:
+ post?.projects?.category?.[0] != null
+ ? typeof post.projects.category[0] === "string"
+ ? post.projects.category[0]
+ : post.projects.category[0]?.name
+ : undefined,
+ tags: markets[0]?.tags ?? [],
+ };
+}
+
+/**
+ * Fetch a single post by numeric ID and return it as a UnifiedEvent.
+ */
+async function fetchEventByPostId(
+ id: string,
+ callApi: CallApi,
+): Promise {
+ const numericId = parseInt(id, 10);
+ if (isNaN(numericId)) return [];
+
+ const data = await callApi("GetPost", { postId: numericId });
+ if (!data || !data.id) return [];
+
+ const event = postToEvent(data);
+ return event ? [event] : [];
+}
+
+/**
+ * Look up an event by slug -- try numeric ID first, then tournament slug,
+ * then client-side slug match.
+ */
+async function fetchEventBySlug(
+ slug: string,
+ callApi: CallApi,
+): Promise {
+ // Try as numeric post ID
+ const byId = await fetchEventByPostId(slug, callApi);
+ if (byId.length > 0) return byId;
+
+ // Try as tournament-slug filter -- fetch posts belonging to that tournament
+ try {
+ const posts = await fetchPostPages(
+ callApi,
+ { tournaments: [slug], with_cp: true, order_by: "-forecasts_count" },
+ 100,
+ );
+
+ if (posts.length > 0) {
+ // Represent the whole tournament as a single event whose markets
+ // are the individual posts (and their sub-questions, expanded)
+ const markets = posts.flatMap((p: any) => expandPost(p, slug));
+
+ return [
+ {
+ id: slug,
+ title: slug,
+ description: "",
+ slug,
+ markets,
+ volume24h: 0,
+ volume: 0,
+ url: `https://www.metaculus.com/tournament/${slug}/`,
+ image: undefined,
+ category: undefined,
+ tags: [],
+ },
+ ];
+ }
+ } catch {
+ // fall through
+ }
+
+ // Finally try slug match against post.slug / post.url_title
+ const posts = await fetchPostPages(
+ callApi,
+ { with_cp: true, order_by: "-forecasts_count" },
+ 500,
+ );
+ const lower = slug.toLowerCase();
+ for (const p of posts) {
+ if (
+ (p.slug ?? "").toLowerCase() === lower ||
+ (p.url_title ?? "").toLowerCase() === lower
+ ) {
+ const event = postToEvent(p);
+ return event ? [event] : [];
+ }
+ }
+
+ return [];
+}
+
+export async function fetchEvents(
+ params: EventFetchParams,
+ callApi: CallApi,
+): Promise {
+ try {
+ // Direct lookup by slug (post ID, tournament slug, or url_title)
+ if (params.slug) {
+ return await fetchEventBySlug(params.slug, callApi);
+ }
+
+ // Direct lookup by eventId (post ID or tournament slug)
+ if (params.eventId) {
+ // Try as numeric post ID first
+ const byId = await fetchEventByPostId(params.eventId, callApi);
+ if (byId.length > 0) return byId;
+
+ // Try as tournament slug
+ return await fetchEventBySlug(params.eventId, callApi);
+ }
+
+ // Default listing -- wrap posts as standalone events
+ const limit = params?.limit ?? 50;
+ const offset = params?.offset ?? 0;
+ const query = (params?.query ?? "").toLowerCase();
+ const statuses = toApiStatuses(params?.status);
+
+ const apiParams: Record = {
+ with_cp: true,
+ };
+ if (statuses) apiParams.statuses = statuses;
+
+ // Sort mapping
+ if (params?.sort === "newest") {
+ apiParams.order_by = "-published_at";
+ } else {
+ apiParams.order_by = "-forecasts_count";
+ }
+
+ const posts = await fetchPostPages(callApi, apiParams, (offset + limit) * (query ? 5 : 1));
+
+ // Client-side keyword filter
+ const filtered = query
+ ? posts.filter((p: any) =>
+ (p.title ?? "").toLowerCase().includes(query) ||
+ (p.question?.description ?? "").toLowerCase().includes(query),
+ )
+ : posts;
+
+ const events: UnifiedEvent[] = [];
+ for (const p of filtered.slice(offset, offset + limit)) {
+ const e = postToEvent(p);
+ if (e) events.push(e);
+ }
+
+ return events;
+ } catch (error: any) {
+ throw metaculusErrorMapper.mapError(error);
+ }
+}
diff --git a/core/src/exchanges/metaculus/fetchMarkets.ts b/core/src/exchanges/metaculus/fetchMarkets.ts
new file mode 100644
index 0000000..5b6c8b3
--- /dev/null
+++ b/core/src/exchanges/metaculus/fetchMarkets.ts
@@ -0,0 +1,255 @@
+import { MarketFetchParams } from "../../BaseExchange";
+import { UnifiedMarket } from "../../types";
+import { expandPost } from "./utils";
+import { metaculusErrorMapper } from "./errors";
+
+type CallApi = (
+ operationId: string,
+ params?: Record,
+) => Promise;
+
+const BATCH_SIZE = 100; // max per page
+const MAX_PAGES = 200; // safety cap (~20 000 posts)
+const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
+
+// Module-level cache for the default active-posts listing
+let cachedPosts: any[] | null = null;
+let lastCacheTime = 0;
+
+export function resetCache(): void {
+ cachedPosts = null;
+ lastCacheTime = 0;
+}
+
+/**
+ * Map pmxt status values to Metaculus `statuses` array param.
+ */
+function toApiStatuses(status?: string): string[] | undefined {
+ if (!status || status === "all") return undefined;
+ if (status === "closed" || status === "inactive") return ["closed", "resolved"];
+ return ["open"]; // "active" or anything else -> open
+}
+
+/**
+ * Fetch pages of posts from /api/posts/ using offset-based pagination.
+ *
+ * Note: group-of-questions posts expand into multiple markets, so the
+ * actual number of markets may exceed the number of raw posts fetched.
+ * The targetCount is a rough guide, not exact.
+ */
+async function fetchPostPages(
+ callApi: CallApi,
+ apiParams: Record,
+ targetCount?: number,
+): Promise {
+ let all: any[] = [];
+ let offset = 0;
+ let page = 0;
+
+ do {
+ const data = await callApi("GetPosts", {
+ ...apiParams,
+ limit: BATCH_SIZE,
+ offset,
+ });
+
+ const results: any[] = data.results ?? [];
+ if (results.length === 0) break;
+
+ all = all.concat(results);
+ offset += results.length;
+ page++;
+
+ // Early-exit when we have enough results (with buffer for filtering)
+ if (targetCount && all.length >= targetCount * 1.5) break;
+
+ if (!data.next) break;
+ } while (page < MAX_PAGES);
+
+ return all;
+}
+
+/**
+ * Expand a list of raw posts into UnifiedMarket[], handling both
+ * single-question and group-of-questions posts.
+ */
+function expandPosts(posts: any[], eventId?: string): UnifiedMarket[] {
+ const markets: UnifiedMarket[] = [];
+ for (const p of posts) {
+ markets.push(...expandPost(p, eventId));
+ }
+ return markets;
+}
+
+/**
+ * Fetch a single post by numeric ID and expand it.
+ * A group post will return multiple markets (one per sub-question).
+ */
+async function fetchMarketById(
+ id: string,
+ callApi: CallApi,
+): Promise {
+ const numericId = parseInt(id, 10);
+ if (isNaN(numericId)) return [];
+
+ const data = await callApi("GetPost", { postId: numericId });
+ if (!data || !data.id) return [];
+
+ return expandPost(data);
+}
+
+/**
+ * Search posts by keyword -- the Metaculus /api/posts/ has no server-side
+ * `search` param, so we fetch a batch of recent open posts and filter
+ * client-side by title/description match.
+ */
+async function searchMarkets(
+ query: string,
+ params: MarketFetchParams | undefined,
+ callApi: CallApi,
+): Promise {
+ const limit = params?.limit ?? 200;
+ const statuses = toApiStatuses(params?.status);
+
+ const apiParams: Record = {
+ order_by: "-forecasts_count",
+ with_cp: true,
+ };
+ if (statuses) apiParams.statuses = statuses;
+
+ // Fetch enough posts to give the client-side filter something to work with
+ const posts = await fetchPostPages(callApi, apiParams, Math.max(limit * 5, 500));
+
+ const lower = query.toLowerCase();
+ const markets: UnifiedMarket[] = [];
+ for (const p of posts) {
+ const title = (p.title ?? "").toLowerCase();
+ const desc = (p.question?.description ?? "").toLowerCase();
+ if (title.includes(lower) || desc.includes(lower)) {
+ markets.push(...expandPost(p));
+ }
+ if (markets.length >= limit) break;
+ }
+
+ return markets.slice(0, limit);
+}
+
+async function fetchMarketsDefault(
+ params: MarketFetchParams | undefined,
+ callApi: CallApi,
+): Promise {
+ const limit = params?.limit ?? 100;
+ const offset = params?.offset ?? 0;
+ const now = Date.now();
+
+ const statuses = toApiStatuses(params?.status ?? "active");
+ const useCache = (!params?.status || params.status === "active") && !params?.sort;
+
+ let posts: any[];
+
+ if (useCache && cachedPosts && now - lastCacheTime < CACHE_TTL) {
+ posts = cachedPosts;
+ } else {
+ const apiParams: Record = {
+ with_cp: true,
+ };
+ if (statuses) apiParams.statuses = statuses;
+
+ // Map sort to the new order_by enum values
+ if (params?.sort === "newest") {
+ apiParams.order_by = "-published_at";
+ } else {
+ apiParams.order_by = "-forecasts_count";
+ }
+
+ const fetchLimit =
+ params?.sort === "volume" || params?.sort === "liquidity"
+ ? 2000
+ : limit + offset;
+
+ posts = await fetchPostPages(callApi, apiParams, fetchLimit);
+
+ if (useCache && posts.length >= 100) {
+ cachedPosts = posts;
+ lastCacheTime = now;
+ }
+ }
+
+ const markets = expandPosts(posts);
+
+ if (params?.sort === "liquidity") {
+ markets.sort((a, b) => b.liquidity - a.liquidity);
+ }
+
+ return markets.slice(offset, offset + limit);
+}
+
+export async function fetchMarkets(
+ params: MarketFetchParams | undefined,
+ callApi: CallApi,
+): Promise {
+ try {
+ // Direct lookup by numeric post/question ID
+ if (params?.marketId) {
+ return await fetchMarketById(params.marketId, callApi);
+ }
+
+ // outcomeId pattern: "-YES" / "-NO" / "-"
+ if (params?.outcomeId) {
+ const id = params.outcomeId.split("-")[0];
+ return await fetchMarketById(id, callApi);
+ }
+
+ // slug: try as numeric ID first (Metaculus slugs are typically words,
+ // but callers may pass the numeric post ID as a slug)
+ if (params?.slug) {
+ const byId = await fetchMarketById(params.slug, callApi);
+ if (byId.length > 0) return byId;
+
+ // Fall back to slug-string match against post.slug / post.url_title
+ const posts = await fetchPostPages(
+ callApi,
+ { with_cp: true, order_by: "-forecasts_count" },
+ 500,
+ );
+ const lower = params.slug.toLowerCase();
+ for (const p of posts) {
+ if (
+ (p.slug ?? "").toLowerCase() === lower ||
+ (p.url_title ?? "").toLowerCase() === lower
+ ) {
+ return expandPost(p);
+ }
+ }
+ return [];
+ }
+
+ // eventId is a tournament slug -- filter posts by that tournament
+ if (params?.eventId) {
+ const apiParams: Record = {
+ tournaments: [params.eventId],
+ with_cp: true,
+ order_by: "-forecasts_count",
+ };
+
+ const posts = await fetchPostPages(
+ callApi,
+ apiParams,
+ params?.limit ?? 1000,
+ );
+
+ const markets = expandPosts(posts);
+ return markets.slice(0, params?.limit ?? markets.length);
+ }
+
+ // Keyword search -- client-side filter (no server-side search param)
+ if (params?.query) {
+ return await searchMarkets(params.query, params, callApi);
+ }
+
+ // Default: recent active posts ordered by forecast count
+ return await fetchMarketsDefault(params, callApi);
+ } catch (error: any) {
+ throw metaculusErrorMapper.mapError(error);
+ }
+}
diff --git a/core/src/exchanges/metaculus/index.ts b/core/src/exchanges/metaculus/index.ts
new file mode 100644
index 0000000..052872d
--- /dev/null
+++ b/core/src/exchanges/metaculus/index.ts
@@ -0,0 +1,195 @@
+import {
+ PredictionMarketExchange,
+ MarketFetchParams,
+ EventFetchParams,
+ ExchangeCredentials,
+} from "../../BaseExchange";
+import { UnifiedMarket, UnifiedEvent, CreateOrderParams, Order } from "../../types";
+import { AuthenticationError } from "../../errors";
+import { parseOpenApiSpec } from "../../utils/openapi";
+import { metaculusApiSpec } from "./api";
+import { metaculusErrorMapper } from "./errors";
+import { BASE_URL } from "./utils";
+import { fetchMarkets } from "./fetchMarkets";
+import { fetchEvents } from "./fetchEvents";
+import { createOrder, CreateOrderContext } from "./createOrder";
+import { cancelOrder, CancelOrderContext } from "./cancelOrder";
+
+/**
+ * Metaculus exchange integration.
+ *
+ * Metaculus is a reputation-based forecasting platform. Unlike CLOB exchanges
+ * (Polymarket, Kalshi), there are no financial stakes -- users submit
+ * probability forecasts and earn reputation points scored on accuracy.
+ *
+ * ## Supported operations
+ *
+ * - **fetchMarkets / fetchEvents**: Browse questions, community predictions,
+ * and tournament structures. Group-of-questions posts are automatically
+ * expanded into individual sub-question markets.
+ *
+ * - **createOrder**: Submit a probability forecast on a question.
+ * Maps `price` (0-1 exclusive) to `probability_yes`. The `side`, `type`,
+ * and `amount` params are ignored since Metaculus forecasts are not
+ * buy/sell orders. See {@link createOrder} for details.
+ *
+ * - **cancelOrder**: Withdraw a forecast from a question. Pass the Metaculus
+ * question ID as the orderId.
+ *
+ * ## Authentication
+ *
+ * Pass `{ apiToken: "..." }` from your Metaculus account settings.
+ * All API operations require a token -- Metaculus no longer allows
+ * unauthenticated access to any endpoint.
+ *
+ * ## Question types
+ *
+ * | Type | fetchMarkets | createOrder |
+ * |------|-------------|-------------|
+ * | Binary | Yes (YES/NO outcomes) | Yes (`price` = probability_yes) |
+ * | Multiple-choice | Yes (one outcome per option) | Yes (redistributes other categories) |
+ * | Group-of-questions | Yes (expanded to sub-question markets) | Yes (per sub-question) |
+ * | Continuous/numeric/date | Yes (read-only HIGHER/LOWER) | No (requires 201-point CDF) |
+ */
+export class MetaculusExchange extends PredictionMarketExchange {
+ override readonly has = {
+ fetchMarkets: true as const,
+ fetchEvents: true as const,
+ createOrder: true as const,
+ cancelOrder: true as const,
+ // Metaculus is a forecasting platform -- no order book, no trading history
+ fetchOHLCV: false as const,
+ fetchOrderBook: false as const,
+ fetchTrades: false as const,
+ fetchOrder: false as const,
+ fetchOpenOrders: false as const,
+ fetchPositions: false as const,
+ fetchBalance: false as const,
+ watchAddress: false as const,
+ unwatchAddress: false as const,
+ watchOrderBook: false as const,
+ watchTrades: false as const,
+ fetchMyTrades: false as const,
+ fetchClosedOrders: false as const,
+ fetchAllOrders: false as const,
+ buildOrder: false as const,
+ submitOrder: false as const,
+ };
+
+ private readonly apiToken?: string;
+
+ constructor(credentials?: ExchangeCredentials) {
+ super(credentials);
+
+ this.apiToken = credentials?.apiToken;
+
+ // Rate-limit conservatively; authenticated users get higher Metaculus quotas
+ this.rateLimit = 500;
+
+ const descriptor = parseOpenApiSpec(metaculusApiSpec, BASE_URL);
+ this.defineImplicitApi(descriptor);
+ }
+
+ get name(): string {
+ return "Metaculus";
+ }
+
+ protected override mapImplicitApiError(error: any): any {
+ throw metaculusErrorMapper.mapError(error);
+ }
+
+ /**
+ * Sign requests with an API token when one is provided.
+ * Metaculus uses token-based auth: `Authorization: Token `.
+ */
+ protected override sign(
+ _method: string,
+ _path: string,
+ _params: Record,
+ ): Record {
+ if (this.apiToken) {
+ return { Authorization: `Token ${this.apiToken}` };
+ }
+ return {};
+ }
+
+ /**
+ * Get auth headers, throwing if no token is configured.
+ * Used by trading methods that require authentication.
+ */
+ private getAuthHeaders(): Record {
+ if (!this.apiToken) {
+ throw new AuthenticationError(
+ 'Metaculus API token required for this operation. '
+ + 'Pass { apiToken: "..." } when constructing MetaculusExchange.',
+ "Metaculus",
+ );
+ }
+ return { Authorization: `Token ${this.apiToken}` };
+ }
+
+ // -------------------------------------------------------------------------
+ // Market Data
+ // -------------------------------------------------------------------------
+
+ protected async fetchMarketsImpl(
+ params?: MarketFetchParams,
+ ): Promise {
+ return fetchMarkets(params, this.callApi.bind(this));
+ }
+
+ protected async fetchEventsImpl(
+ params: EventFetchParams,
+ ): Promise {
+ return fetchEvents(params, this.callApi.bind(this));
+ }
+
+ // -------------------------------------------------------------------------
+ // Trading (Forecasting)
+ // -------------------------------------------------------------------------
+
+ /**
+ * Submit a probability forecast on a Metaculus question.
+ *
+ * Maps from the unified `createOrder` interface:
+ * - `price` -> the probability to forecast (0-1 exclusive)
+ * - `outcomeId` -> encodes the question ID and type
+ * - `side`, `type`, `amount` -> ignored (Metaculus forecasts are not orders)
+ *
+ * For binary questions, sets `probability_yes` directly.
+ * For multiple-choice, redistributes other categories proportionally.
+ * Continuous questions are not supported (throws InvalidOrder).
+ *
+ * @throws {AuthenticationError} If no API token is configured.
+ * @throws {InvalidOrder} If the question type is continuous or price is invalid.
+ */
+ override async createOrder(params: CreateOrderParams): Promise {
+ const ctx: CreateOrderContext = {
+ http: this.http,
+ getAuthHeaders: () => this.getAuthHeaders(),
+ fetchOutcomes: async (marketId: string) => {
+ const markets = await this.fetchMarkets({ marketId });
+ return markets.length > 0 ? markets[0].outcomes : [];
+ },
+ };
+ return createOrder(params, ctx);
+ }
+
+ /**
+ * Withdraw a forecast from a Metaculus question.
+ *
+ * The `orderId` should be the Metaculus question ID (numeric).
+ * If you used createOrder, extract the question ID from the
+ * outcomeId (the part before the hyphen).
+ *
+ * @throws {AuthenticationError} If no API token is configured.
+ * @throws {ValidationError} If orderId is not a valid question ID.
+ */
+ override async cancelOrder(orderId: string): Promise {
+ const ctx: CancelOrderContext = {
+ http: this.http,
+ getAuthHeaders: () => this.getAuthHeaders(),
+ };
+ return cancelOrder(orderId, ctx);
+ }
+}
diff --git a/core/src/exchanges/metaculus/utils.ts b/core/src/exchanges/metaculus/utils.ts
new file mode 100644
index 0000000..2277257
--- /dev/null
+++ b/core/src/exchanges/metaculus/utils.ts
@@ -0,0 +1,351 @@
+import { UnifiedMarket, MarketOutcome } from "../../types";
+import { addBinaryOutcomes } from "../../utils/market-utils";
+
+/**
+ * Base URL passed to parseOpenApiSpec to override the spec's servers[0].url.
+ * The generated api.ts already has "https://www.metaculus.com/api" as its server URL,
+ * so this constant must match exactly -- do NOT add a trailing slash or path suffix.
+ * Paths in the spec (/posts/, /posts/{postId}/) are appended directly by BaseExchange.
+ */
+export const BASE_URL = "https://www.metaculus.com/api";
+
+/**
+ * Map a Metaculus post `status` to pmxt unified status.
+ *
+ * Metaculus post statuses: "open", "closed", "resolved", "upcoming"
+ */
+export function mapStatus(status: string): "active" | "closed" {
+ switch ((status ?? "").toLowerCase()) {
+ case "open":
+ case "upcoming":
+ return "active";
+ default:
+ return "closed";
+ }
+}
+
+/**
+ * Extract the community prediction probability from a question object.
+ *
+ * For all question types the recency-weighted aggregation exposes a `centers`
+ * array where `centers[0]` is the median / central estimate, already normalised
+ * to [0, 1] by the API.
+ *
+ * Accepts either a Post (reads from post.question) or a bare Question object.
+ *
+ * @returns A number in [0, 1], or 0.5 if no prediction is available.
+ */
+function extractCommunityProbability(questionOrPost: any): number {
+ // Support both post.question and bare question objects
+ const question = questionOrPost?.question ?? questionOrPost;
+ const latest = question?.aggregations?.recency_weighted?.latest;
+
+ if (!latest) return 0.5;
+
+ const centers: number[] | undefined = latest.centers;
+ if (Array.isArray(centers) && centers.length > 0 && typeof centers[0] === "number") {
+ return Math.max(0, Math.min(1, centers[0]));
+ }
+
+ // Fallback: some binary posts expose forecast_values[0] as the Yes probability
+ const fv: number[] | undefined = latest.forecast_values;
+ if (Array.isArray(fv) && fv.length > 0 && typeof fv[0] === "number") {
+ return Math.max(0, Math.min(1, fv[0]));
+ }
+
+ return 0.5;
+}
+
+/**
+ * Build the tag list from a Post's project associations.
+ * Combines taxonomy tags and categories so consumers can filter by either.
+ */
+function buildTags(post: any): string[] {
+ const tags: string[] = [];
+ const projects = post?.projects ?? {};
+
+ // Explicit tags
+ const tagList: any[] = projects.tag ?? [];
+ for (const t of tagList) {
+ const label = typeof t === "string" ? t : t?.name;
+ if (label && !tags.includes(label)) tags.push(label);
+ }
+
+ // Categories (useful for broad filtering)
+ const catList: any[] = projects.category ?? [];
+ for (const c of catList) {
+ const label = typeof c === "string" ? c : c?.name;
+ if (label && !tags.includes(label)) tags.push(label);
+ }
+
+ // Question type as a tag for easy filtering
+ const qType = post?.question?.type;
+ if (qType && !tags.includes(qType)) tags.push(qType);
+
+ return tags;
+}
+
+/**
+ * Build outcomes for a Metaculus question.
+ *
+ * OutcomeId format uses the **question ID** (not the post ID) so that
+ * `createOrder` can extract the correct ID for the forecast API.
+ *
+ * - Binary: `-YES` / `-NO`
+ * - Multiple-choice: `-`
+ * - Continuous: `-HIGHER` / `-LOWER` (read-only, not tradeable)
+ *
+ * Raw aggregation data is exposed in each outcome's `metadata` so consumers
+ * can use it directly.
+ *
+ * @param question The Metaculus Question object (not the Post wrapper).
+ * @param postId The parent post ID, used as the marketId on each outcome.
+ */
+function buildOutcomes(question: any, postId: string, medianProb: number): MarketOutcome[] {
+ const questionId = String(question?.id ?? postId);
+ const type = (question?.type || "binary").toLowerCase();
+
+ const latest = question?.aggregations?.recency_weighted?.latest ?? null;
+ const sharedMeta = {
+ question_type: type,
+ question_id: Number(questionId),
+ aggregations: latest,
+ resolution: question?.resolution ?? null,
+ scaling: question?.scaling ?? null,
+ possibilities: question?.possibilities ?? null,
+ };
+
+ // Multiple choice: one outcome per option, each independently forecastable
+ if (type === "multiple_choice") {
+ const options: any[] = question?.options ?? [];
+ if (options.length > 0) {
+ const histogram: number[] | undefined = latest?.histogram ?? undefined;
+ return options.map((opt: any, idx: number) => {
+ const label =
+ typeof opt === "string"
+ ? opt
+ : opt?.label ?? opt?.value ?? `Option ${idx + 1}`;
+ const price =
+ Array.isArray(histogram) && typeof histogram[idx] === "number"
+ ? Math.max(0, Math.min(1, histogram[idx]))
+ : 1 / Math.max(options.length, 1);
+ return {
+ outcomeId: `${questionId}-${idx}`,
+ marketId: postId,
+ label,
+ price,
+ priceChange24h: 0,
+ metadata: { ...sharedMeta, choice_index: idx },
+ } as MarketOutcome;
+ });
+ }
+ }
+
+ // Binary: Yes/No outcomes
+ if (type === "binary") {
+ return [
+ {
+ outcomeId: `${questionId}-YES`,
+ marketId: postId,
+ label: "Yes",
+ price: medianProb,
+ priceChange24h: 0,
+ metadata: sharedMeta,
+ },
+ {
+ outcomeId: `${questionId}-NO`,
+ marketId: postId,
+ label: "No",
+ price: Math.max(0, Math.min(1, 1 - medianProb)),
+ priceChange24h: 0,
+ metadata: sharedMeta,
+ },
+ ];
+ }
+
+ // Continuous / numeric / date -- not tradeable via createOrder.
+ // Displayed as synthetic Higher/Lower for read-only price indication.
+ return [
+ {
+ outcomeId: `${questionId}-HIGHER`,
+ marketId: postId,
+ label: "Higher",
+ price: medianProb,
+ priceChange24h: 0,
+ metadata: sharedMeta,
+ },
+ {
+ outcomeId: `${questionId}-LOWER`,
+ marketId: postId,
+ label: "Lower",
+ price: Math.max(0, Math.min(1, 1 - medianProb)),
+ priceChange24h: 0,
+ metadata: sharedMeta,
+ },
+ ];
+}
+
+/**
+ * Convert a raw Metaculus Post (v3 /api/posts/ response item) into a
+ * `UnifiedMarket`.
+ *
+ * Returns `null` for group-of-questions posts -- callers should use
+ * {@link expandPost} instead, which handles both single and group posts.
+ *
+ * @param post Raw post object from the Metaculus API.
+ * @param eventId Optional parent event ID (tournament slug) to override
+ * the value derived from post.projects.tournament.
+ */
+export function mapMarketToUnified(post: any, eventId?: string): UnifiedMarket | null {
+ if (!post || !post.id) return null;
+
+ // Group-of-questions posts have no top-level question -- they must be
+ // expanded into individual sub-question markets via expandPost().
+ if (post.group_of_questions && !post.question) return null;
+
+ const postId = String(post.id);
+ const question = post.question ?? {};
+ const medianProb = extractCommunityProbability(post);
+ const outcomes = buildOutcomes(question, postId, medianProb);
+
+ // Resolution date -- prefer scheduled_resolve_time, fall back to close time
+ const resolveDateStr =
+ post.scheduled_resolve_time ??
+ question.scheduled_resolve_time ??
+ post.scheduled_close_time ??
+ post.actual_close_time;
+ const resolutionDate = resolveDateStr
+ ? new Date(resolveDateStr)
+ : new Date("2099-01-01T00:00:00Z");
+
+ const tags = buildTags(post);
+
+ // Primary category label
+ const categoryList: any[] = post?.projects?.category ?? [];
+ const category =
+ categoryList.length > 0
+ ? typeof categoryList[0] === "string"
+ ? categoryList[0]
+ : categoryList[0]?.name
+ : undefined;
+
+ // Forecaster count -- proxy for liquidity (no monetary values on Metaculus)
+ const forecastCount = Number(
+ post.nr_forecasters ?? question.nr_forecasters ?? 0,
+ );
+
+ // Derive eventId from first tournament slug if not explicitly provided
+ const tournamentList: any[] = post?.projects?.tournament ?? [];
+ const derivedEventId =
+ tournamentList.length > 0
+ ? typeof tournamentList[0] === "string"
+ ? tournamentList[0]
+ : tournamentList[0]?.slug
+ : undefined;
+
+ const resolvedEventId = eventId ?? derivedEventId;
+
+ const um: UnifiedMarket = {
+ marketId: postId,
+ eventId: resolvedEventId,
+ title: post.title ?? question.title ?? "",
+ description:
+ question.description ??
+ question.resolution_criteria ??
+ "",
+ slug: post.slug ?? post.url_title ?? undefined,
+ outcomes,
+ resolutionDate,
+ volume24h: 0, // Metaculus has no monetary volume
+ volume: 0,
+ liquidity: forecastCount, // re-purposed as forecaster count
+ openInterest: forecastCount,
+ url: `https://www.metaculus.com/questions/${postId}/`,
+ image: post.projects?.default_project?.header_image ?? undefined,
+ category,
+ tags,
+ };
+
+ addBinaryOutcomes(um);
+ return um;
+}
+
+/**
+ * Expand a group-of-questions post into individual sub-question markets.
+ *
+ * Each sub-question becomes its own `UnifiedMarket` with:
+ * - `marketId` = sub-question's post_id (for API lookups via GetPost)
+ * - outcomeIds based on the sub-question's question.id (for forecast API)
+ * - `eventId` = parent post ID (the group acts as a container)
+ * - `metadata.groupPostId` on each outcome for traceability
+ *
+ * @param post A group-of-questions post (post.group_of_questions.questions[]).
+ * @param eventId Optional override for the eventId field.
+ */
+function mapGroupPostToMarkets(post: any, eventId?: string): UnifiedMarket[] {
+ const group = post.group_of_questions;
+ if (!group?.questions?.length) return [];
+
+ const parentPostId = String(post.id);
+ const groupEventId = eventId ?? parentPostId;
+
+ const markets: UnifiedMarket[] = [];
+ for (const subQuestion of group.questions) {
+ // Build a synthetic post that mapMarketToUnified can process.
+ // Use the sub-question's post_id as the post id if available,
+ // otherwise fall back to the sub-question's own id.
+ const syntheticPost = {
+ id: subQuestion.post_id ?? subQuestion.id,
+ title: subQuestion.title ?? subQuestion.label ?? post.title,
+ question: subQuestion,
+ // Inherit metadata from the parent post
+ slug: post.slug,
+ url_title: post.url_title,
+ projects: post.projects,
+ nr_forecasters: subQuestion.nr_forecasters ?? post.nr_forecasters,
+ scheduled_resolve_time: subQuestion.scheduled_resolve_time ?? post.scheduled_resolve_time,
+ scheduled_close_time: subQuestion.scheduled_close_time ?? post.scheduled_close_time,
+ actual_close_time: subQuestion.actual_close_time ?? post.actual_close_time,
+ status: post.status,
+ };
+
+ const market = mapMarketToUnified(syntheticPost, groupEventId);
+ if (market) {
+ // Tag each outcome with the parent group post ID for traceability
+ for (const outcome of market.outcomes) {
+ if (outcome.metadata) {
+ (outcome.metadata as any).groupPostId = Number(parentPostId);
+ }
+ }
+ markets.push(market);
+ }
+ }
+
+ return markets;
+}
+
+/**
+ * Convert a raw Metaculus post into one or more `UnifiedMarket` objects.
+ *
+ * Handles all post types:
+ * - Single-question posts (binary, multiple-choice, continuous) -> 1 market
+ * - Group-of-questions posts -> N markets (one per sub-question)
+ *
+ * Use this instead of calling `mapMarketToUnified` directly when processing
+ * feed results, since a single API post can yield multiple tradeable markets.
+ *
+ * @param post Raw post object from the Metaculus API.
+ * @param eventId Optional parent event ID (tournament slug).
+ */
+export function expandPost(post: any, eventId?: string): UnifiedMarket[] {
+ if (!post || !post.id) return [];
+
+ // Group posts: expand each sub-question into its own market
+ if (post.group_of_questions && !post.question) {
+ return mapGroupPostToMarkets(post, eventId);
+ }
+
+ // Single-question post
+ const market = mapMarketToUnified(post, eventId);
+ return market ? [market] : [];
+}
diff --git a/core/src/exchanges/probable/auth.ts b/core/src/exchanges/probable/auth.ts
index 2c87d30..0d126f5 100644
--- a/core/src/exchanges/probable/auth.ts
+++ b/core/src/exchanges/probable/auth.ts
@@ -52,10 +52,14 @@ export class ProbableAuth {
passphrase: this.credentials.passphrase!,
};
+ // @prob/clob may resolve a different viem copy than this package; types then
+ // disagree on WalletClient. Runtime shape is identical.
+ const walletForClob = wallet as any;
+
if (chainId === 56) {
this.clobClient = createClobClient({
chainId: 56,
- wallet,
+ wallet: walletForClob,
credential,
});
} else {
@@ -63,7 +67,7 @@ export class ProbableAuth {
this.clobClient = createClobClient({
chainId,
baseUrl,
- wallet,
+ wallet: walletForClob,
credential,
});
}
diff --git a/core/src/index.ts b/core/src/index.ts
index 4389511..aaf0c35 100644
--- a/core/src/index.ts
+++ b/core/src/index.ts
@@ -11,6 +11,7 @@ export * from './exchanges/probable';
export * from './exchanges/baozi';
export * from './exchanges/myriad';
export * from './exchanges/opinion';
+export * from './exchanges/metaculus';
export * from './server/app';
export * from './server/utils/port-manager';
export * from './server/utils/lock-file';
@@ -23,6 +24,7 @@ import { ProbableExchange } from './exchanges/probable';
import { BaoziExchange } from './exchanges/baozi';
import { MyriadExchange } from './exchanges/myriad';
import { OpinionExchange } from './exchanges/opinion';
+import { MetaculusExchange } from './exchanges/metaculus';
const pmxt = {
Polymarket: PolymarketExchange,
@@ -33,6 +35,7 @@ const pmxt = {
Baozi: BaoziExchange,
Myriad: MyriadExchange,
Opinion: OpinionExchange,
+ Metaculus: MetaculusExchange,
};
export const Polymarket = PolymarketExchange;
@@ -43,5 +46,6 @@ export const Probable = ProbableExchange;
export const Baozi = BaoziExchange;
export const Myriad = MyriadExchange;
export const Opinion = OpinionExchange;
+export const Metaculus = MetaculusExchange;
export default pmxt;
diff --git a/core/src/server/app.ts b/core/src/server/app.ts
index b0cd078..c853e48 100644
--- a/core/src/server/app.ts
+++ b/core/src/server/app.ts
@@ -8,6 +8,7 @@ import { ProbableExchange } from "../exchanges/probable";
import { BaoziExchange } from "../exchanges/baozi";
import { MyriadExchange } from "../exchanges/myriad";
import { OpinionExchange } from "../exchanges/opinion";
+import { MetaculusExchange } from "../exchanges/metaculus";
import { ExchangeCredentials } from "../BaseExchange";
import { BaseError } from "../errors";
@@ -21,6 +22,7 @@ const defaultExchanges: Record = {
baozi: null,
myriad: null,
opinion: null,
+ metaculus: null,
};
export async function startServer(port: number, accessToken: string) {
@@ -64,7 +66,12 @@ export async function startServer(port: number, accessToken: string) {
// If credentials are provided, create a new instance for this request
// Otherwise, use the singleton instance
let exchange: any;
- if (credentials && (credentials.privateKey || credentials.apiKey)) {
+ if (
+ credentials &&
+ (credentials.privateKey ||
+ credentials.apiKey ||
+ credentials.apiToken)
+ ) {
exchange = createExchange(exchangeName, credentials);
} else {
if (!defaultExchanges[exchangeName]) {
@@ -216,6 +223,11 @@ function createExchange(name: string, credentials?: ExchangeCredentials) {
credentials?.privateKey || process.env.OPINION_PRIVATE_KEY,
funderAddress: credentials?.funderAddress,
});
+ case "metaculus":
+ return new MetaculusExchange({
+ apiToken:
+ credentials?.apiToken || process.env.METACULUS_API_TOKEN,
+ });
default:
throw new Error(`Unknown exchange: ${name}`);
}
diff --git a/core/src/server/openapi.yaml b/core/src/server/openapi.yaml
index 0a8e2ef..18714e0 100644
--- a/core/src/server/openapi.yaml
+++ b/core/src/server/openapi.yaml
@@ -1144,6 +1144,7 @@ components:
- baozi
- myriad
- opinion
+ - metaculus
required: true
description: The prediction market exchange to target.
schemas:
diff --git a/core/test/compliance/shared.ts b/core/test/compliance/shared.ts
index 70df61d..a1676d7 100644
--- a/core/test/compliance/shared.ts
+++ b/core/test/compliance/shared.ts
@@ -462,5 +462,10 @@ export function initExchange(name: string, cls: any) {
walletAddress: process.env.OPINION_WALLET_ADDRESS?.trim(),
});
}
+ if (name === "MetaculusExchange") {
+ return new cls({
+ apiToken: process.env.METACULUS_API_TOKEN?.trim(),
+ });
+ }
return new cls();
}
diff --git a/package-lock.json b/package-lock.json
index 7d89c4b..8762119 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6185,9 +6185,9 @@
"license": "ISC"
},
"node_modules/handlebars": {
- "version": "4.7.8",
- "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
- "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
+ "version": "4.7.9",
+ "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
+ "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -9336,19 +9336,19 @@
}
},
"node_modules/ts-jest": {
- "version": "29.4.6",
- "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.6.tgz",
- "integrity": "sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==",
+ "version": "29.4.9",
+ "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz",
+ "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"bs-logger": "^0.2.6",
"fast-json-stable-stringify": "^2.1.0",
- "handlebars": "^4.7.8",
+ "handlebars": "^4.7.9",
"json5": "^2.2.3",
"lodash.memoize": "^4.1.2",
"make-error": "^1.3.6",
- "semver": "^7.7.3",
+ "semver": "^7.7.4",
"type-fest": "^4.41.0",
"yargs-parser": "^21.1.1"
},
@@ -9365,7 +9365,7 @@
"babel-jest": "^29.0.0 || ^30.0.0",
"jest": "^29.0.0 || ^30.0.0",
"jest-util": "^29.0.0 || ^30.0.0",
- "typescript": ">=4.3 <6"
+ "typescript": ">=4.3 <7"
},
"peerDependenciesMeta": {
"@babel/core": {
@@ -9389,9 +9389,9 @@
}
},
"node_modules/ts-jest/node_modules/semver": {
- "version": "7.7.3",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
- "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+ "version": "7.7.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+ "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"dev": true,
"license": "ISC",
"bin": {
@@ -9972,7 +9972,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"jest": "^30.3.0",
- "ts-jest": "^29.4.6",
+ "ts-jest": "^29.4.9",
"typescript": "^5.0.0"
}
},
diff --git a/readme.md b/readme.md
index 85e8b64..a6e4e3a 100644
--- a/readme.md
+++ b/readme.md
@@ -65,6 +65,8 @@ Different prediction market platforms have different APIs, data formats, and con
MyriadOpinion
+
+ Metaculus
[Feature Support & Compliance](core/COMPLIANCE.md).
diff --git a/sdks/python/pmxt/__init__.py b/sdks/python/pmxt/__init__.py
index 9a41593..686615a 100644
--- a/sdks/python/pmxt/__init__.py
+++ b/sdks/python/pmxt/__init__.py
@@ -17,7 +17,7 @@
"""
from .client import Exchange
-from ._exchanges import Polymarket, Limitless, Kalshi, KalshiDemo, Probable, Baozi, Myriad, Opinion
+from ._exchanges import Polymarket, Limitless, Kalshi, KalshiDemo, Probable, Baozi, Myriad, Opinion, Metaculus
from .server_manager import ServerManager
from .errors import (
PmxtError,
@@ -79,6 +79,7 @@ def restart_server():
"Baozi",
"Myriad",
"Opinion",
+ "Metaculus",
"Exchange",
# Server Management
"ServerManager",
diff --git a/sdks/python/pmxt/_exchanges.py b/sdks/python/pmxt/_exchanges.py
index 463edcb..36e4ddd 100644
--- a/sdks/python/pmxt/_exchanges.py
+++ b/sdks/python/pmxt/_exchanges.py
@@ -280,3 +280,28 @@ def __init__(
base_url=base_url,
auto_start_server=auto_start_server,
)
+
+
+class Metaculus(Exchange):
+ """Metaculus exchange client."""
+
+ def __init__(
+ self,
+ api_token: Optional[str] = None,
+ base_url: str = "http://localhost:3847",
+ auto_start_server: bool = True,
+ ):
+ """
+ Initialize Metaculus client.
+
+ Args:
+ api_token: API token for authentication (optional; required for Metaculus API access)
+ base_url: Base URL of the PMXT sidecar server
+ auto_start_server: Automatically start server if not running (default: True)
+ """
+ super().__init__(
+ exchange_name="metaculus",
+ api_token=api_token,
+ base_url=base_url,
+ auto_start_server=auto_start_server,
+ )
diff --git a/sdks/python/pmxt/client.py b/sdks/python/pmxt/client.py
index 569afc4..3be0cb6 100644
--- a/sdks/python/pmxt/client.py
+++ b/sdks/python/pmxt/client.py
@@ -258,6 +258,7 @@ def __init__(
exchange_name: str,
api_key: Optional[str] = None,
private_key: Optional[str] = None,
+ api_token: Optional[str] = None,
base_url: str = "http://localhost:3847",
auto_start_server: bool = True,
proxy_address: Optional[str] = None,
@@ -270,12 +271,14 @@ def __init__(
exchange_name: Name of the exchange ("polymarket" or "kalshi")
api_key: API key for authentication (optional)
private_key: Private key for authentication (optional)
+ api_token: Metaculus-style bearer token (optional)
base_url: Base URL of the PMXT sidecar server
auto_start_server: Automatically start server if not running (default: True)
"""
self.exchange_name = exchange_name.lower()
self.api_key = api_key
self.private_key = private_key
+ self.api_token = api_token
self.proxy_address = proxy_address
self.signature_type = signature_type
self.markets: Dict[str, "UnifiedMarket"] = {}
@@ -356,7 +359,7 @@ def _get_auth_headers(self) -> Dict[str, str]:
def _get_credentials_dict(self) -> Optional[Dict[str, Any]]:
"""Build credentials dictionary for API requests."""
- if not self.api_key and not self.private_key:
+ if not self.api_key and not self.private_key and not self.api_token:
return None
creds = {}
@@ -364,6 +367,8 @@ def _get_credentials_dict(self) -> Optional[Dict[str, Any]]:
creds["apiKey"] = self.api_key
if self.private_key:
creds["privateKey"] = self.private_key
+ if self.api_token:
+ creds["apiToken"] = self.api_token
if self.proxy_address:
creds["funderAddress"] = self.proxy_address
if self.signature_type is not None:
diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json
index b733285..c4a9564 100644
--- a/sdks/typescript/package.json
+++ b/sdks/typescript/package.json
@@ -49,7 +49,7 @@
"@types/jest": "^30.0.0",
"@types/node": "^20.0.0",
"jest": "^30.3.0",
- "ts-jest": "^29.4.6",
+ "ts-jest": "^29.4.9",
"typescript": "^5.0.0"
}
}