feat(forecast): deterministic quantile path so dscr_p10 stops reading seeded rows
dscr_p10 is the feature TW-01 fires on, and so the origin of nearly every alert an officer sees — but nothing had written forecast_point since the seeder. A headline number computed from demo data is worse than no headline number, because it looks identical to a real one. ForecastStrategy is a seam shaped like ScoringStrategy: the LightGBM quantile heads implement it later and the swap is one property. The forecast and the score answer different questions and fail differently, so they get separate seams. The method is fully explainable. Baseline is the median whole-month net position from the ledger, median so one festival month does not become the expectation, and the partial current month is discarded. Drift comes from margin_squeeze, else feed_cost_change, capped so one sharp month does not extrapolate into a cliff. Spread widens with the square root of the horizon and again for every input we did not have. Missing inputs widen the band, never lower the centre. A pessimistic P50 built on ignorance would manufacture breaches. An enterprise with too little ledger gets no path rather than a fabricated one, so dscr_p10 resolves NO_FORECAST visibly. forecast_point gains engine/model_version. Seeded rows carry neither, and udyam.forecast.trust-seeded decides whether dscr_p10 may fall back to them. Left true so the demo portfolio stays populated; with it false the picture is honest — 6 enterprises forecast, 136 NO_FORECAST, and trip-wires firing drops from 34 to 5. The forecast reads margin features and dscr_p10 reads the forecast, so features are computed twice per enterprise rather than letting either half read a previous run's answer. Seeder now writes 150 days of ledger, not 31: the baseline needs whole months and one month leaves nothing to take a median of. Ledger joins loans in the backfill pass, keyed on clientId. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,10 @@ public class FeatureEngineeringService {
|
||||
@Value("${udyam.features.min-zscore-samples:6}")
|
||||
private int minZScoreSamples;
|
||||
|
||||
/** Whether {@code dscr_p10} may read forecast rows the seeder wrote. See {@code dscrP10}. */
|
||||
@Value("${udyam.forecast.trust-seeded:true}")
|
||||
private boolean trustSeededForecasts;
|
||||
|
||||
// ── entry points ────────────────────────────────────────
|
||||
|
||||
/** One pass over the whole book. Consent-revoked enterprises are skipped, not computed. */
|
||||
@@ -425,6 +429,15 @@ public class FeatureEngineeringService {
|
||||
*/
|
||||
private Computed dscrP10(String enterpriseId, LocalDate asOfDate) {
|
||||
List<ForecastPoint> points = forecastPointRepository.findByEnterpriseIdOrderByHorizonAsc(enterpriseId);
|
||||
|
||||
// Seeded rows carry no engine. Once a real forecast engine exists, a
|
||||
// demo path and a computed one are indistinguishable downstream — the
|
||||
// score, the band and the alert all look equally earned. Setting this
|
||||
// false makes an enterprise nobody can forecast report NO_FORECAST
|
||||
// instead of borrowing the seeder's optimism.
|
||||
if (!trustSeededForecasts) {
|
||||
points = points.stream().filter(point -> point.getEngine() != null).toList();
|
||||
}
|
||||
if (points.isEmpty()) return Computed.missing(FeatureCatalog.NO_FORECAST);
|
||||
|
||||
Long latestRun = points.stream().map(ForecastPoint::getRunId)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.enterprise.LedgerEntry;
|
||||
import com.lincfusion.udyamlinc.enterprise.LedgerEntryRepository;
|
||||
import com.lincfusion.udyamlinc.features.FeatureCatalog;
|
||||
import com.lincfusion.udyamlinc.features.FeatureValue;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* A quantile path without a model.
|
||||
*
|
||||
* <p>This exists because {@code dscr_p10} — the feature TW-01 fires on, and so
|
||||
* the origin of most alerts an officer ever sees — was reading forecast rows
|
||||
* nothing had written since the seeder. A headline number computed from demo
|
||||
* data is worse than no headline number, because it looks identical to a real
|
||||
* one.
|
||||
*
|
||||
* <p>The method is deliberately simple and wholly explainable:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>Baseline.</b> The median monthly net position from the ledger. Median
|
||||
* rather than mean because a single festival month should not become the
|
||||
* expectation.</li>
|
||||
* <li><b>Drift.</b> Margin pressure from the features — {@code margin_squeeze}
|
||||
* first, else {@code feed_cost_change} inverted — applied per month and
|
||||
* capped, so a sharp input-cost move bends the path without running away
|
||||
* over six months.</li>
|
||||
* <li><b>Spread.</b> P10 and P90 widen with the square root of the horizon,
|
||||
* because uncertainty compounds with time rather than linearly, and widen
|
||||
* again for every input we did not have.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>Not knowing makes the band wider, never the centre lower.</b> Missing
|
||||
* inputs must not quietly become a pessimistic P50 — that would manufacture
|
||||
* breaches out of ignorance. They widen the spread, so P10 falls and confidence
|
||||
* drops, which is the honest shape of "we know less about this one".
|
||||
*
|
||||
* <p>An enterprise with no ledger at all gets no forecast rather than a fabricated
|
||||
* one. {@code dscr_p10} then resolves {@code NO_FORECAST}, visibly.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DeterministicForecastStrategy implements ForecastStrategy {
|
||||
|
||||
private final LedgerEntryRepository ledgerRepository;
|
||||
|
||||
@Value("${udyam.forecast.horizon-months:6}")
|
||||
private int horizonMonths;
|
||||
|
||||
/** Months of ledger read to establish the baseline. */
|
||||
@Value("${udyam.forecast.lookback-months:6}")
|
||||
private int lookbackMonths;
|
||||
|
||||
/** Fewest distinct months of ledger before a baseline means anything. */
|
||||
@Value("${udyam.forecast.min-months:2}")
|
||||
private int minMonths;
|
||||
|
||||
/** P10/P90 half-width at h=1, as a fraction of P50, when every input is present. */
|
||||
@Value("${udyam.forecast.base-spread:0.18}")
|
||||
private double baseSpread;
|
||||
|
||||
/** Added to the spread for each feature the path had to do without. */
|
||||
@Value("${udyam.forecast.missing-input-penalty:0.10}")
|
||||
private double missingInputPenalty;
|
||||
|
||||
/** Ceiling on the spread — beyond this a forecast says nothing worth storing. */
|
||||
@Value("${udyam.forecast.max-spread:0.75}")
|
||||
private double maxSpread;
|
||||
|
||||
/** Ceiling on cumulative margin drift, either direction. */
|
||||
@Value("${udyam.forecast.max-drift:0.30}")
|
||||
private double maxDrift;
|
||||
|
||||
@Override
|
||||
public String engine() {
|
||||
return "DETERMINISTIC";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String version() {
|
||||
return "deterministic-v1";
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ForecastPoint> forecast(Enterprise enterprise, Sector sector,
|
||||
Map<String, FeatureValue> features, LocalDate asOfDate) {
|
||||
|
||||
List<BigDecimal> monthlyNet = monthlyNetPositions(enterprise.getEnterpriseId(), asOfDate);
|
||||
if (monthlyNet.size() < minMonths) {
|
||||
// No ledger, no forecast. A path invented from the loan record alone
|
||||
// would be a restatement of the obligation dressed up as a prediction.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
BigDecimal baseline = median(monthlyNet);
|
||||
if (baseline == null || baseline.signum() <= 0) {
|
||||
// A business whose median month is negative is already in distress and
|
||||
// the ledger says so directly; projecting it forward adds nothing an
|
||||
// officer cannot already see.
|
||||
return List.of();
|
||||
}
|
||||
|
||||
BigDecimal obligation = enterprise.getMonthlyInstalment() == null
|
||||
? BigDecimal.ZERO
|
||||
: enterprise.getMonthlyInstalment();
|
||||
|
||||
MarginSignal margin = marginSignal(features);
|
||||
double spread = Math.min(maxSpread, baseSpread + margin.missingInputs() * missingInputPenalty);
|
||||
|
||||
List<ForecastPoint> points = new ArrayList<>(horizonMonths);
|
||||
YearMonth start = YearMonth.from(asOfDate);
|
||||
|
||||
for (int h = 1; h <= horizonMonths; h++) {
|
||||
// Drift compounds with the horizon but is capped, so a single sharp
|
||||
// input-cost month does not extrapolate into a cliff at month six.
|
||||
double drift = Math.max(-maxDrift, Math.min(maxDrift, margin.perMonthDrift() * h));
|
||||
BigDecimal p50 = baseline.multiply(BigDecimal.valueOf(1 + drift))
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
// Uncertainty grows with the square root of time, not linearly.
|
||||
double horizonSpread = Math.min(maxSpread, spread * Math.sqrt(h));
|
||||
BigDecimal p10 = p50.multiply(BigDecimal.valueOf(1 - horizonSpread)).setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal p90 = p50.multiply(BigDecimal.valueOf(1 + horizonSpread)).setScale(2, RoundingMode.HALF_UP);
|
||||
|
||||
points.add(ForecastPoint.builder()
|
||||
.enterpriseId(enterprise.getEnterpriseId())
|
||||
.horizon(h)
|
||||
.month(start.plusMonths(h).toString())
|
||||
.p10(p10)
|
||||
.p50(p50)
|
||||
.p90(p90)
|
||||
.obligation(obligation)
|
||||
.breach(obligation.signum() > 0 && p10.compareTo(obligation) < 0)
|
||||
.engine(engine())
|
||||
.modelVersion(version())
|
||||
.build());
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
// ── baseline ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Net position per calendar month from the ledger, most recent first.
|
||||
*
|
||||
* <p>Only entries dated on or before {@code asOfDate} count — a forecast
|
||||
* recomputed for last month must not see money that moved since.
|
||||
*/
|
||||
private List<BigDecimal> monthlyNetPositions(String enterpriseId, LocalDate asOfDate) {
|
||||
LocalDate from = asOfDate.minusMonths(lookbackMonths).withDayOfMonth(1);
|
||||
List<LedgerEntry> entries = ledgerRepository
|
||||
.findByEnterpriseIdAndEntryDateGreaterThanEqualOrderByEntryDateDesc(enterpriseId, from);
|
||||
|
||||
Map<YearMonth, BigDecimal> byMonth = new TreeMap<>(Comparator.reverseOrder());
|
||||
for (LedgerEntry entry : entries) {
|
||||
if (entry.getEntryDate() == null || entry.getEntryDate().isAfter(asOfDate)) continue;
|
||||
if (entry.getAmount() == null) continue;
|
||||
// STOCK entries record inventory movement, not cash — including them
|
||||
// would double-count the sale that follows.
|
||||
String kind = entry.getKind();
|
||||
if (!"IN".equalsIgnoreCase(kind) && !"OUT".equalsIgnoreCase(kind)) continue;
|
||||
|
||||
BigDecimal signed = "IN".equalsIgnoreCase(kind) ? entry.getAmount() : entry.getAmount().negate();
|
||||
byMonth.merge(YearMonth.from(entry.getEntryDate()), signed, BigDecimal::add);
|
||||
}
|
||||
|
||||
// The current month is partial by definition and would read as a collapse.
|
||||
byMonth.remove(YearMonth.from(asOfDate));
|
||||
return new ArrayList<>(byMonth.values());
|
||||
}
|
||||
|
||||
private BigDecimal median(List<BigDecimal> values) {
|
||||
if (values.isEmpty()) return null;
|
||||
List<BigDecimal> sorted = new ArrayList<>(values);
|
||||
sorted.sort(Comparator.naturalOrder());
|
||||
int mid = sorted.size() / 2;
|
||||
if (sorted.size() % 2 == 1) return sorted.get(mid);
|
||||
return sorted.get(mid - 1).add(sorted.get(mid))
|
||||
.divide(BigDecimal.valueOf(2), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
// ── drift ───────────────────────────────────────────────
|
||||
|
||||
/** The per-month drift the margin features imply, and how many were missing. */
|
||||
private record MarginSignal(double perMonthDrift, int missingInputs) {
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code margin_squeeze} is input change minus output change, so a positive
|
||||
* value means costs are outrunning prices and cash should be expected to
|
||||
* fall. Where it is absent, {@code feed_cost_change} alone carries the same
|
||||
* sign with less of the picture — and counts as a missing input, which widens
|
||||
* the band.
|
||||
*/
|
||||
private MarginSignal marginSignal(Map<String, FeatureValue> features) {
|
||||
BigDecimal squeeze = valueOf(features, FeatureCatalog.MARGIN_SQUEEZE);
|
||||
if (squeeze != null) {
|
||||
return new MarginSignal(-squeeze.doubleValue(), 0);
|
||||
}
|
||||
BigDecimal feedCost = valueOf(features, FeatureCatalog.FEED_COST_CHANGE);
|
||||
if (feedCost != null) {
|
||||
return new MarginSignal(-feedCost.doubleValue(), 1);
|
||||
}
|
||||
// No margin view at all: a flat centre and the widest band this method
|
||||
// will produce. Flat rather than falling, because inventing a decline we
|
||||
// cannot evidence would manufacture breaches.
|
||||
return new MarginSignal(0.0, 2);
|
||||
}
|
||||
|
||||
private BigDecimal valueOf(Map<String, FeatureValue> features, String code) {
|
||||
FeatureValue value = features.get(code);
|
||||
return value == null ? null : value.getValue();
|
||||
}
|
||||
}
|
||||
@@ -55,4 +55,15 @@ public class ForecastPoint {
|
||||
|
||||
/** True when p10 falls short of the obligation. Stored so queries stay simple. */
|
||||
private Boolean breach;
|
||||
|
||||
/**
|
||||
* DETERMINISTIC | MODEL, and the version that produced it. Null on rows the
|
||||
* seeder wrote, which is the point: a demo path and a computed one must never
|
||||
* be indistinguishable once both can exist in the same table.
|
||||
*/
|
||||
@Column(length = 16)
|
||||
private String engine;
|
||||
|
||||
@Column(length = 32)
|
||||
private String modelVersion;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.lincfusion.udyamlinc.risk;
|
||||
|
||||
import com.lincfusion.udyamlinc.enterprise.Enterprise;
|
||||
import com.lincfusion.udyamlinc.features.FeatureValue;
|
||||
import com.lincfusion.udyamlinc.master.Sector;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The seam the quantile model arrives through, deliberately shaped like
|
||||
* {@link ScoringStrategy}.
|
||||
*
|
||||
* <p>The forecast and the score answer different questions and fail in different
|
||||
* ways, so they get separate seams: "how much cash will there be, and how sure
|
||||
* are we" is not "how stressed is this enterprise". Keeping them apart means the
|
||||
* LightGBM quantile heads can replace the forecast without touching the risk
|
||||
* engine, and vice versa.
|
||||
*
|
||||
* <p>Implementations read features, the ledger and the loan record. Like a
|
||||
* scoring strategy they must not reach into the signal tables — a market price
|
||||
* reaches a forecast as a feature or not at all.
|
||||
*/
|
||||
public interface ForecastStrategy {
|
||||
|
||||
/** DETERMINISTIC | MODEL — stored on every point it produces. */
|
||||
String engine();
|
||||
|
||||
/** Recorded on each point so a historic breach names the engine that predicted it. */
|
||||
String version();
|
||||
|
||||
/**
|
||||
* The quantile path for one enterprise, horizon 1…n months ahead.
|
||||
*
|
||||
* <p>Returning an empty list is legitimate and means "not enough to forecast
|
||||
* from". It leaves {@code dscr_p10} null with {@code NO_FORECAST}, which is
|
||||
* the honest answer and visibly different from a forecast that says the
|
||||
* enterprise is fine.
|
||||
*/
|
||||
List<ForecastPoint> forecast(Enterprise enterprise, Sector sector,
|
||||
Map<String, FeatureValue> features, LocalDate asOfDate);
|
||||
}
|
||||
@@ -47,7 +47,9 @@ public class RiskEngineService {
|
||||
private final StressScoreRepository scoreRepository;
|
||||
private final ReasonCodeRepository reasonRepository;
|
||||
private final RiskAlertRepository alertRepository;
|
||||
private final ForecastPointRepository forecastPointRepository;
|
||||
private final ScoringStrategy strategy;
|
||||
private final ForecastStrategy forecastStrategy;
|
||||
|
||||
@Value("${udyam.forecast.horizon-months:6}")
|
||||
private int horizon;
|
||||
@@ -61,8 +63,11 @@ public class RiskEngineService {
|
||||
StressScoreRepository scoreRepository,
|
||||
ReasonCodeRepository reasonRepository,
|
||||
RiskAlertRepository alertRepository,
|
||||
ForecastPointRepository forecastPointRepository,
|
||||
List<ScoringStrategy> strategies,
|
||||
@Value("${udyam.risk.engine:RULE}") String engineName) {
|
||||
List<ForecastStrategy> forecastStrategies,
|
||||
@Value("${udyam.risk.engine:RULE}") String engineName,
|
||||
@Value("${udyam.forecast.engine:DETERMINISTIC}") String forecastEngineName) {
|
||||
|
||||
this.enterpriseRepository = enterpriseRepository;
|
||||
this.sectorRepository = sectorRepository;
|
||||
@@ -73,13 +78,22 @@ public class RiskEngineService {
|
||||
this.scoreRepository = scoreRepository;
|
||||
this.reasonRepository = reasonRepository;
|
||||
this.alertRepository = alertRepository;
|
||||
this.forecastPointRepository = forecastPointRepository;
|
||||
this.forecastStrategy = forecastStrategies.stream()
|
||||
.filter(s -> s.engine().equalsIgnoreCase(forecastEngineName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"No ForecastStrategy for udyam.forecast.engine=" + forecastEngineName
|
||||
+ "; available: " + forecastStrategies.stream().map(ForecastStrategy::engine).toList()));
|
||||
this.strategy = strategies.stream()
|
||||
.filter(s -> s.engine().equalsIgnoreCase(engineName))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalStateException(
|
||||
"No ScoringStrategy for udyam.risk.engine=" + engineName
|
||||
+ "; available: " + strategies.stream().map(ScoringStrategy::engine).toList()));
|
||||
log.info("Risk engine bound to {} ({})", this.strategy.engine(), this.strategy.modelVersion());
|
||||
log.info("Risk engine bound to {} ({}), forecast to {} ({})",
|
||||
this.strategy.engine(), this.strategy.modelVersion(),
|
||||
this.forecastStrategy.engine(), this.forecastStrategy.version());
|
||||
}
|
||||
|
||||
// ── batch ───────────────────────────────────────────────
|
||||
@@ -144,14 +158,21 @@ public class RiskEngineService {
|
||||
LocalDateTime observedAsOf = LocalDateTime.now();
|
||||
|
||||
FeatureSnapshot snapshot = featureService.computeFor(enterprise, asOfDate, observedAsOf, run.getId());
|
||||
Map<String, FeatureValue> features = new HashMap<>();
|
||||
featureValueRepository.findByEnterpriseIdAndValidForDateOrderByFeatureCode(
|
||||
enterprise.getEnterpriseId(), asOfDate)
|
||||
.forEach(value -> features.put(value.getFeatureCode(), value));
|
||||
Map<String, FeatureValue> features = loadFeatures(enterprise, asOfDate);
|
||||
|
||||
Sector sector = sectorRepository.findById(enterprise.getSectorId()).orElse(null);
|
||||
String sectorCode = sector == null ? null : sector.getCode();
|
||||
|
||||
// The forecast reads the margin features, and dscr_p10 reads the forecast.
|
||||
// That circle is resolved by computing features twice rather than by
|
||||
// letting either half read a previous run's answer: a path built on last
|
||||
// month's margin, or a dscr_p10 describing a forecast this run replaced,
|
||||
// would both be wrong in a way nothing downstream could detect.
|
||||
if (generateForecast(enterprise, sector, features, asOfDate, run.getId())) {
|
||||
snapshot = featureService.computeFor(enterprise, asOfDate, observedAsOf, run.getId());
|
||||
features = loadFeatures(enterprise, asOfDate);
|
||||
}
|
||||
|
||||
ScoreOutcome scored = strategy.score(enterprise, sector, features);
|
||||
|
||||
// Trip-wires read the same features, never the score.
|
||||
@@ -172,6 +193,33 @@ public class RiskEngineService {
|
||||
snapshot.getId(), scored, outcomes, fired, raised);
|
||||
}
|
||||
|
||||
private Map<String, FeatureValue> loadFeatures(Enterprise enterprise, LocalDate asOfDate) {
|
||||
Map<String, FeatureValue> features = new HashMap<>();
|
||||
featureValueRepository.findByEnterpriseIdAndValidForDateOrderByFeatureCode(
|
||||
enterprise.getEnterpriseId(), asOfDate)
|
||||
.forEach(value -> features.put(value.getFeatureCode(), value));
|
||||
return features;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes this run's quantile path, and reports whether it wrote one.
|
||||
*
|
||||
* <p>An empty path is a legitimate outcome — an enterprise with too little
|
||||
* ledger to forecast from gets no rows, and {@code dscr_p10} stays null with
|
||||
* {@code NO_FORECAST}. Returning false then skips the second feature pass,
|
||||
* because there is nothing new for it to see.
|
||||
*/
|
||||
private boolean generateForecast(Enterprise enterprise, Sector sector,
|
||||
Map<String, FeatureValue> features,
|
||||
LocalDate asOfDate, Long runId) {
|
||||
List<ForecastPoint> points = forecastStrategy.forecast(enterprise, sector, features, asOfDate);
|
||||
if (points.isEmpty()) return false;
|
||||
|
||||
points.forEach(point -> point.setRunId(runId));
|
||||
forecastPointRepository.saveAll(points);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A RED trip-wire escalates whatever the score said; a WATCH one only lifts a
|
||||
* STANDARD. The score is left untouched — the band is a decision, the score is
|
||||
|
||||
@@ -49,6 +49,15 @@ public class UdyamDataSeeder implements CommandLineRunner {
|
||||
public static final String DEFAULT_ENTERPRISE_PIN = "1234";
|
||||
|
||||
private static final int TOTAL = 142;
|
||||
|
||||
/**
|
||||
* Days of ledger history per demo enterprise. Five months rather than one,
|
||||
* because the deterministic forecast takes its baseline from the median of
|
||||
* whole calendar months and discards the partial current one — a single month
|
||||
* of entries leaves nothing to take a median of, and every enterprise reports
|
||||
* NO_FORECAST.
|
||||
*/
|
||||
private static final int LEDGER_DAYS = 150;
|
||||
private static final Map<String, Integer> TARGET = Map.of("RED", 9, "WATCH", 31, "STANDARD", 102);
|
||||
private static final String MODEL_VERSION = "rule-v0.9.3";
|
||||
|
||||
@@ -420,11 +429,20 @@ public class UdyamDataSeeder implements CommandLineRunner {
|
||||
* per-enterprise guard, so this is safe on every boot.
|
||||
*/
|
||||
private void backfillPortfolio() {
|
||||
long before = loanRecordRepository.count();
|
||||
enterpriseRepository.findAll().forEach(this::seedLoans);
|
||||
long added = loanRecordRepository.count() - before;
|
||||
if (added > 0) {
|
||||
log.info("Backfilled {} loan records onto an already-seeded portfolio", added);
|
||||
long loansBefore = loanRecordRepository.count();
|
||||
long ledgerBefore = ledgerRepository.count();
|
||||
|
||||
List<Enterprise> all = enterpriseRepository.findAll();
|
||||
all.forEach(this::seedLoans);
|
||||
all.forEach(this::seedLedger);
|
||||
|
||||
long loansAdded = loanRecordRepository.count() - loansBefore;
|
||||
long ledgerAdded = ledgerRepository.count() - ledgerBefore;
|
||||
if (loansAdded > 0) {
|
||||
log.info("Backfilled {} loan records onto an already-seeded portfolio", loansAdded);
|
||||
}
|
||||
if (ledgerAdded > 0) {
|
||||
log.info("Backfilled {} ledger entries onto an already-seeded portfolio", ledgerAdded);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,7 +655,7 @@ public class UdyamDataSeeder implements CommandLineRunner {
|
||||
String[] outCats = {"INPUT", "WAGES", "TRANSPORT", "EMI"};
|
||||
List<LedgerEntry> entries = new ArrayList<>();
|
||||
|
||||
for (int d = 30; d >= 0; d--) {
|
||||
for (int d = LEDGER_DAYS; d >= 0; d--) {
|
||||
int count = 1 + r.nextInt(3);
|
||||
for (int i = 0; i < count; i++) {
|
||||
boolean in = r.nextDouble() > 0.45;
|
||||
@@ -652,7 +670,13 @@ public class UdyamDataSeeder implements CommandLineRunner {
|
||||
.build());
|
||||
}
|
||||
}
|
||||
ledgerRepository.saveAll(entries);
|
||||
// clientId is generated on the phone and is the identity an offline entry
|
||||
// keeps through sync, so it is also the right idempotence key here: a
|
||||
// re-run extends the history backwards without duplicating a single day.
|
||||
List<LedgerEntry> fresh = entries.stream()
|
||||
.filter(entry -> ledgerRepository.findByClientId(entry.getClientId()).isEmpty())
|
||||
.toList();
|
||||
if (!fresh.isEmpty()) ledgerRepository.saveAll(fresh);
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────
|
||||
|
||||
@@ -116,3 +116,24 @@ udyam.features.climate-window-weeks=8
|
||||
udyam.features.min-zscore-samples=6
|
||||
|
||||
logging.level.com.lincfusion.udyamlinc=INFO
|
||||
|
||||
# ── Forecast (ARCHITECTURE R1 seam) ─────────────────────────
|
||||
# DETERMINISTIC until the LightGBM quantile heads exist. Both implement
|
||||
# ForecastStrategy, so the swap is this one property.
|
||||
udyam.forecast.engine=${UDYAM_FORECAST_ENGINE:DETERMINISTIC}
|
||||
udyam.forecast.lookback-months=6
|
||||
udyam.forecast.min-months=2
|
||||
# P10/P90 half-width at h=1 when every input is present; each missing input adds
|
||||
# to it, and it grows with the square root of the horizon. Missing inputs widen
|
||||
# the band, never lower the centre — a pessimistic P50 built on ignorance would
|
||||
# manufacture breaches.
|
||||
udyam.forecast.base-spread=0.18
|
||||
udyam.forecast.missing-input-penalty=0.10
|
||||
udyam.forecast.max-spread=0.75
|
||||
udyam.forecast.max-drift=0.30
|
||||
# Whether dscr_p10 may fall back to the seeder's forecast rows, which carry no
|
||||
# engine. True keeps the demo portfolio populated; false makes every enterprise
|
||||
# the forecast cannot reach report NO_FORECAST, and TW-01 skip rather than fire
|
||||
# on a number nothing computed. Set false before any run whose alerts are meant
|
||||
# to be believed.
|
||||
udyam.forecast.trust-seeded=${UDYAM_TRUST_SEEDED_FORECASTS:true}
|
||||
|
||||
Reference in New Issue
Block a user