You are viewing a plain text version of this content. The canonical link for it is here.
Posted to c-commits@axis.apache.org by ro...@apache.org on 2026/04/16 13:20:36 UTC

(axis-axis2-c-core) 02/04: Port quant-correctness fixes from Axis2/Java financial samples

This is an automated email from the ASF dual-hosted git repository.

robertlazarski pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/axis-axis2-c-core.git

commit 3c0da9df0873195cc351206780c12b910d476e0e
Author: Robert Lazarski <ro...@gmail.com>
AuthorDate: Wed Apr 15 14:06:38 2026 -1000

    Port quant-correctness fixes from Axis2/Java financial samples
    
    Brings the C financial benchmark service up to the same behavioral
    contract as the Axis2/Java sample so that callers targeting either
    language get the same (correct) result for the same input.
    
    Seven fixes applied:
    
      - Monte Carlo median now correctly averages the two central elements
        for even n_simulations (matches NumPy/R sample-median behavior; a
        single-index approximation produced small reconciliation differences).
      - Variance accumulator (sumSq/N - mean^2) is now clamped to 0.0 before
        sqrt to prevent NaN propagation from floating-point cancellation
        on near-constant samples.
      - finbench_run_monte_carlo now fail-fasts on initial_value <= 0
        (GBM is undefined for non-positive starting values) and also on
        out-of-range n_simulations, n_periods, and negative volatility.
      - Scenario analysis now enforces FINBENCH_MAX_SCENARIOS per asset
        with a specific error message rather than silently truncating the
        input. A new n_scenarios_requested field on the asset struct records
        the raw JSON count before capping so the validator can detect
        overflow without risking fixed-array out-of-bounds writes.
      - Scenario analysis now fail-fasts on current_price <= 0 (return is
        undefined) and on missing or empty scenarios array, rather than
        silently skipping the asset and quietly omitting a holding from
        the result.
      - upside_downside_ratio now returns INFINITY for all-upside portfolios
        (downside near zero, upside positive) and NaN for the zero-zero case,
        rather than 0.0 (which falsely implied "no upside").
    
    The existing C-side exp() overflow cap in the GBM inner loop is left
    unchanged; Axis2/Java deliberately does NOT clamp exp() overflow in its
    equivalent code path. Both choices are defensible — C caps defensively
    to keep the response well-formed; Java lets NaN propagate as an alarm
    for extreme inputs. The divergence is intentional and documented.
    
    Reviewed with gemini-2.5-pro from a senior buy-side quant perspective
    (math/behavioral correctness, C-correctness including fixed-array
    bounds and err-buf handling, validation completeness all passed).
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <no...@anthropic.com>
---
 .../src/financial_benchmark_service.c              | 147 +++++++++++++++++++--
 .../src/financial_benchmark_service.h              |  11 +-
 2 files changed, 148 insertions(+), 10 deletions(-)

diff --git a/samples/user_guide/financial-benchmark-service/src/financial_benchmark_service.c b/samples/user_guide/financial-benchmark-service/src/financial_benchmark_service.c
index 0ad5533b9..da0f13a47 100644
--- a/samples/user_guide/financial-benchmark-service/src/financial_benchmark_service.c
+++ b/samples/user_guide/financial-benchmark-service/src/financial_benchmark_service.c
@@ -826,6 +826,41 @@ finbench_run_monte_carlo(
         return response;
     }
 
+    /* Fail-fast input validation — the simulation body has no guards of
+     * its own for these, and silently-wrong results on garbage input
+     * would be worse than an explicit rejection. */
+    if (request->initial_value <= 0.0) {
+        response->status = axutil_strdup(env, FINBENCH_STATUS_FAILED);
+        response->error_message = axutil_strdup(env,
+            "initial_value must be > 0. Geometric Brownian Motion evolves "
+            "multiplicatively through exp(...) and is undefined for "
+            "non-positive starting values.");
+        return response;
+    }
+    if (request->n_simulations <= 0 ||
+        request->n_simulations > FINBENCH_MAX_SIMULATIONS) {
+        char err_buf[192];
+        snprintf(err_buf, sizeof(err_buf),
+            "n_simulations=%d is out of range [1, %d].",
+            request->n_simulations, FINBENCH_MAX_SIMULATIONS);
+        response->status = axutil_strdup(env, FINBENCH_STATUS_FAILED);
+        response->error_message = axutil_strdup(env, err_buf);
+        return response;
+    }
+    if (request->n_periods <= 0) {
+        response->status = axutil_strdup(env, FINBENCH_STATUS_FAILED);
+        response->error_message = axutil_strdup(env,
+            "n_periods must be >= 1 (at least one time step is required).");
+        return response;
+    }
+    if (request->volatility < 0.0) {
+        response->status = axutil_strdup(env, FINBENCH_STATUS_FAILED);
+        response->error_message = axutil_strdup(env,
+            "volatility must be >= 0 (negative volatility is not a "
+            "meaningful input).");
+        return response;
+    }
+
     /* Allocate array for final values (needed for percentiles).
      * Cast to size_t to prevent integer overflow on 32-bit platforms
      * when n_simulations is large (e.g., 1M × 8 bytes = 8 MB). */
@@ -907,6 +942,12 @@ finbench_run_monte_carlo(
     /* Calculate statistics */
     double mean = sum_final / request->n_simulations;
     double variance = (sum_sq / request->n_simulations) - (mean * mean);
+    /* Clamp tiny negative variance produced by floating-point cancellation
+     * in the (sum_sq/N - mean^2) formula before the final sqrt. Magnitudes
+     * here are typically ~1e-16 to 1e-12 and occur for near-constant
+     * samples; a negative value would propagate NaN through std_dev and
+     * downstream consumers. */
+    if (variance < 0.0) variance = 0.0;
 
     /* Sort for percentiles */
     qsort(final_values, request->n_simulations, sizeof(double), compare_doubles);
@@ -914,7 +955,15 @@ finbench_run_monte_carlo(
     int n_sims = request->n_simulations;
     int idx_5  = (int)(0.05 * n_sims);
     int idx_1  = (int)(0.01 * n_sims);
-    int idx_50 = n_sims / 2;
+
+    /* Sample median of a sorted array: for odd N take the middle element,
+     * for even N average the two central elements. Using a single index
+     * (n_sims/2) is only an approximation for even N and can produce small
+     * reconciliation differences against NumPy/R, which both implement
+     * the average-of-two rule. */
+    double median = (n_sims % 2 == 0)
+        ? (final_values[n_sims / 2 - 1] + final_values[n_sims / 2]) / 2.0
+        : final_values[n_sims / 2];
 
     /* Calculate CVaR (Expected Shortfall) - average of worst 5% */
     double cvar_sum = 0.0;
@@ -947,7 +996,7 @@ finbench_run_monte_carlo(
     /* Populate response */
     response->status = axutil_strdup(env, FINBENCH_STATUS_SUCCESS);
     response->mean_final_value = mean;
-    response->median_final_value = final_values[idx_50];
+    response->median_final_value = median;
     response->std_dev_final_value = sqrt(variance);
     response->var_95 = request->initial_value - final_values[idx_5];
     response->var_99 = request->initial_value - final_values[idx_1];
@@ -1218,9 +1267,16 @@ finbench_scenario_request_create_from_json(
         json_object *sc_arr = NULL;
         if (json_object_object_get_ex(asset_obj, "scenarios", &sc_arr) &&
             json_object_is_type(sc_arr, json_type_array)) {
-            int n_sc = json_object_array_length(sc_arr);
-            if (n_sc > FINBENCH_MAX_SCENARIOS) n_sc = FINBENCH_MAX_SCENARIOS;
+            int n_sc_raw = json_object_array_length(sc_arr);
+            int n_sc = (n_sc_raw > FINBENCH_MAX_SCENARIOS)
+                ? FINBENCH_MAX_SCENARIOS : n_sc_raw;
+            /* Store both counts so finbench_calculate_scenarios can fail
+             * fast when the caller exceeded the cap rather than silently
+             * truncating.  The fixed-size arrays below must never be
+             * indexed past FINBENCH_MAX_SCENARIOS, so we still cap the
+             * fill loop at n_sc. */
             a->n_scenarios = n_sc;
+            a->n_scenarios_requested = n_sc_raw;
             for (j = 0; j < n_sc; j++) {
                 json_object *sc = json_object_array_get_idx(sc_arr, j);
                 json_object *sc_field = NULL;
@@ -1330,8 +1386,63 @@ finbench_calculate_scenarios(
         finbench_asset_scenario_t *a = &request->assets[i];
         double prob_sum = 0.0;
         int k;
+        char err_buf[256];
+
+        /* Fail-fast: current_price must be positive — scenario return is
+         * computed as (scenario_price / current_price - 1), undefined for
+         * non-positive current_price.  Previously this asset was silently
+         * skipped, which quietly omitted a holding from the portfolio
+         * result without any indication to the caller. */
+        if (a->current_price <= 0.0) {
+            snprintf(err_buf, sizeof(err_buf),
+                "Asset index %d (id=%" PRId64 "): current_price=%.8f is not "
+                "positive. Scenario analysis computes return as "
+                "(price / current_price - 1), which is undefined for "
+                "non-positive current_price.",
+                i, a->asset_id, a->current_price);
+            response->status = axutil_strdup(env, FINBENCH_STATUS_FAILED);
+            response->error_message = axutil_strdup(env, err_buf);
+            AXIS2_LOG_ERROR(env->log, AXIS2_LOG_SI,
+                "ScenarioAnalysis: current_price validation failed - %s",
+                err_buf);
+            return response;
+        }
+
+        /* Fail-fast: must supply at least one scenario — without scenarios
+         * the asset contributes nothing to the weighted portfolio, which
+         * is almost certainly a caller mistake (empty JSON array or
+         * malformed input) rather than a deliberate input. */
+        if (a->n_scenarios <= 0) {
+            snprintf(err_buf, sizeof(err_buf),
+                "Asset index %d (id=%" PRId64 "): scenarios array is missing "
+                "or empty. At least one scenario {price, probability} is "
+                "required.",
+                i, a->asset_id);
+            response->status = axutil_strdup(env, FINBENCH_STATUS_FAILED);
+            response->error_message = axutil_strdup(env, err_buf);
+            AXIS2_LOG_ERROR(env->log, AXIS2_LOG_SI,
+                "ScenarioAnalysis: scenarios validation failed - %s", err_buf);
+            return response;
+        }
 
-        if (a->n_scenarios <= 0) continue;
+        /* Fail-fast: reject requests that exceeded the per-asset scenario
+         * cap.  The parser records n_scenarios_requested so the rejection
+         * message can tell the caller the cap was exceeded rather than
+         * silently truncating the input.  Suggest coalescing rare outcomes
+         * to stay within the cap. */
+        if (a->n_scenarios_requested > FINBENCH_MAX_SCENARIOS) {
+            snprintf(err_buf, sizeof(err_buf),
+                "Asset index %d (id=%" PRId64 "): scenarios count %d exceeds "
+                "maximum %d. Coalesce low-probability outcomes to stay "
+                "within the cap.",
+                i, a->asset_id, a->n_scenarios_requested,
+                FINBENCH_MAX_SCENARIOS);
+            response->status = axutil_strdup(env, FINBENCH_STATUS_FAILED);
+            response->error_message = axutil_strdup(env, err_buf);
+            AXIS2_LOG_ERROR(env->log, AXIS2_LOG_SI,
+                "ScenarioAnalysis: MAX_SCENARIOS exceeded - %s", err_buf);
+            return response;
+        }
 
         for (k = 0; k < a->n_scenarios; k++) {
             prob_sum += a->probabilities[k];
@@ -1340,7 +1451,6 @@ finbench_calculate_scenarios(
         if (prob_sum < (1.0 - prob_tolerance) ||
             prob_sum > (1.0 + prob_tolerance)) {
 
-            char err_buf[256];
             snprintf(err_buf, sizeof(err_buf),
                 "Asset index %d (id=%" PRId64 "): scenario probabilities sum to "
                 "%.8f, expected 1.0 (tolerance %.2g). "
@@ -1365,7 +1475,9 @@ finbench_calculate_scenarios(
         double asset_upside = 0.0, asset_downside = 0.0;
         double asset_weighted_value = 0.0;
 
-        if (a->current_price <= 0.0 || a->n_scenarios <= 0) continue;
+        /* Step 1 above has already rejected any asset with non-positive
+         * current_price, missing scenarios, or an over-cap scenario count,
+         * so no defensive skip is needed here. */
 
         for (j = 0; j < a->n_scenarios; j++) {
             double prob = a->probabilities[j];
@@ -1478,8 +1590,25 @@ finbench_calculate_scenarios(
     response->weighted_value      = portfolio_weighted_value;
     response->upside_potential    = total_upside;
     response->downside_risk       = total_downside;
-    response->upside_downside_ratio =
-        (total_downside > 0.0) ? (total_upside / total_downside) : 0.0;
+    /* Upside/downside ratio — three distinct regimes:
+     *   downside > 0, finite      : standard ratio (the common path)
+     *   downside ~ 0, upside > 0  : INFINITY (all-upside portfolio;
+     *                               returning 0.0 here would falsely
+     *                               imply "no upside")
+     *   downside ~ 0, upside ~ 0  : NAN (both sides effectively zero —
+     *                               the ratio is genuinely undefined)
+     * The 1e-9 threshold avoids treating floating-point residue from the
+     * probability-weighted sums as a real non-zero side.
+     * Note on JSON: json-c serializes these as "Infinity" / "NaN", which
+     * are valid JavaScript Number literals but NOT strict JSON per RFC
+     * 8259 — strict parsers must handle the tokens or map them to null. */
+    if (total_downside > 1e-9) {
+        response->upside_downside_ratio = total_upside / total_downside;
+    } else if (total_upside > 1e-9) {
+        response->upside_downside_ratio = INFINITY;
+    } else {
+        response->upside_downside_ratio = NAN;
+    }
     response->calc_time_us        = linear_us; /* primary timing: linear */
     response->lookups_performed   = n_lookups;
     response->memory_used_kb      = finbench_get_memory_usage_kb();
diff --git a/samples/user_guide/financial-benchmark-service/src/financial_benchmark_service.h b/samples/user_guide/financial-benchmark-service/src/financial_benchmark_service.h
index daf791287..cc8ca2b93 100644
--- a/samples/user_guide/financial-benchmark-service/src/financial_benchmark_service.h
+++ b/samples/user_guide/financial-benchmark-service/src/financial_benchmark_service.h
@@ -301,9 +301,18 @@ typedef struct finbench_asset_scenario
     /** Probability weights for each scenario */
     double probabilities[FINBENCH_MAX_SCENARIOS];
 
-    /** Number of scenarios defined */
+    /** Number of scenarios defined (after capping at FINBENCH_MAX_SCENARIOS) */
     int n_scenarios;
 
+    /**
+     * Raw scenario count as supplied in the JSON request, BEFORE capping.
+     * When the caller sends more than FINBENCH_MAX_SCENARIOS scenarios the
+     * parser only fills the first FINBENCH_MAX_SCENARIOS entries but records
+     * the original count here so that finbench_calculate_scenarios can
+     * fail fast with a specific error rather than silently dropping data.
+     */
+    int n_scenarios_requested;
+
     /** Position size (shares) */
     double position_size;