fix: drop untradable predictions instead of the whole proposal

One untradable ticker rejected everything alongside it. In a single day that was
93 proposals discarding 171 predictions, and 76 of those named something we could
trade perfectly well. They were lost because a sibling in the same response said
EURUSD.

Tradability is a filter, so it applies per prediction now. The untradable one is
dropped and logged, its siblings are kept, and the stored payload records what
was removed so the filtering is auditable rather than invisible.

Lookahead deliberately still rejects the entire proposal. Evidence that did not
exist at the proposal's own cutoff means the response is corrupt rather than
merely untradable, and keeping the rest of it would hide the one thing most worth
seeing. Both halves are pinned by tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WnNxwxfXSbeNtjvtz5gayb
This commit is contained in:
ImBenji
2026-09-04 20:16:55 +01:00
co-authored by Claude Opus 5
parent 75e183aaa3
commit f464c94708
2 changed files with 71 additions and 10 deletions
+32 -10
View File
@@ -96,15 +96,37 @@ function verifyEvidence(archiveDb, articleIds, informationCutoff = null) {
function acceptProposal(intelligenceDb, archiveDb, raw, metadata = {}) { function acceptProposal(intelligenceDb, archiveDb, raw, metadata = {}) {
const proposal = normalizeProposal(raw, metadata); const proposal = normalizeProposal(raw, metadata);
for (const prediction of proposal.predictions) {
// Tradability is a filter, not an integrity failure, so it is applied per
// prediction. One untradable ticker used to reject the entire proposal and take
// its valid siblings down with it: in a single day 93 proposals were rejected
// this way, discarding 171 predictions of which 76 named something we could
// trade perfectly well.
const allowlisted = intelligenceDb.prepare(
"SELECT tradable FROM autonomy_instruments WHERE symbol = ? AND active = 1 AND tradable = 1"
);
const dropped = [];
const tradable = proposal.predictions.filter((prediction) => {
if (allowlisted.get(prediction.instrument)) return true;
dropped.push(prediction.instrument);
return false;
});
if (dropped.length) {
console.warn(`[coordinator] dropped ${dropped.length} untradable instrument(s): ${dropped.join(', ')}`
+ ` (kept ${tradable.length})`);
}
// Lookahead stays all or nothing. A proposal citing evidence that did not exist
// at its own cutoff is corrupt rather than merely untradable, and quietly keeping
// the rest of it would hide exactly the thing we most need to see.
for (const prediction of tradable) {
if (!verifyEvidence(archiveDb, prediction.evidenceArticleIds, proposal.informationCutoff)) { if (!verifyEvidence(archiveDb, prediction.evidenceArticleIds, proposal.informationCutoff)) {
throw new Error(`proposal references missing evidence for ${prediction.instrument}`); throw new Error(`proposal references missing evidence for ${prediction.instrument}`);
} }
const instrument = intelligenceDb.prepare(
"SELECT tradable FROM autonomy_instruments WHERE symbol = ? AND active = 1 AND tradable = 1"
).get(prediction.instrument);
if (!instrument) throw new Error(`instrument is not currently allowlisted: ${prediction.instrument}`);
} }
// what actually got stored, plus a record of what was filtered and why
const stored = { ...proposal, predictions: tradable, droppedInstruments: dropped };
const insert = intelligenceDb.prepare(` const insert = intelligenceDb.prepare(`
INSERT INTO autonomy_proposals INSERT INTO autonomy_proposals
(event_id, payload, information_cutoff, coordinator_model, prompt_version, status) (event_id, payload, information_cutoff, coordinator_model, prompt_version, status)
@@ -117,18 +139,18 @@ function acceptProposal(intelligenceDb, archiveDb, raw, metadata = {}) {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`); `);
const tx = intelligenceDb.transaction(() => { const tx = intelligenceDb.transaction(() => {
const proposalResult = insert.run(metadata.eventId || null, JSON.stringify(proposal), proposal.informationCutoff, const proposalResult = insert.run(metadata.eventId || null, JSON.stringify(stored), stored.informationCutoff,
proposal.coordinatorModel, proposal.promptVersion); stored.coordinatorModel, stored.promptVersion);
for (const prediction of proposal.predictions) { for (const prediction of tradable) {
insertPrediction.run(proposalResult.lastInsertRowid, metadata.eventId || null, prediction.instrument, insertPrediction.run(proposalResult.lastInsertRowid, metadata.eventId || null, prediction.instrument,
prediction.direction, prediction.eventType, prediction.causalChannel, prediction.horizonDays, prediction.direction, prediction.eventType, prediction.causalChannel, prediction.horizonDays,
proposal.informationCutoff, JSON.stringify(prediction.evidenceArticleIds), prediction.invalidationCondition, stored.informationCutoff, JSON.stringify(prediction.evidenceArticleIds), prediction.invalidationCondition,
metadata.learningEligible ? 1 : 0, metadata.strategyVersion || 'autonomy-1', metadata.learningEligible ? 1 : 0, metadata.strategyVersion || 'autonomy-1',
metadata.origin || 'live', metadata.replayRunId || null); metadata.origin || 'live', metadata.replayRunId || null);
} }
return Number(proposalResult.lastInsertRowid); return Number(proposalResult.lastInsertRowid);
}); });
return { proposalId: tx(), predictionCount: proposal.predictions.length }; return { proposalId: tx(), predictionCount: tradable.length, droppedInstruments: dropped };
} }
function recordRejectedProposal(intelligenceDb, raw, metadata = {}, reason = 'validation failed') { function recordRejectedProposal(intelligenceDb, raw, metadata = {}, reason = 'validation failed') {
+39
View File
@@ -354,3 +354,42 @@ test('a budget failure is transient but a bad key is not', () => {
assert.equal(isTransientCoordinatorFailure('TypeError: fetch failed'), true); assert.equal(isTransientCoordinatorFailure('TypeError: fetch failed'), true);
assert.equal(isTransientCoordinatorFailure('request failed with 503'), true); assert.equal(isTransientCoordinatorFailure('request failed with 503'), true);
}); });
test('an untradable instrument drops itself, not its valid siblings', () => {
const archive = new Database(':memory:');
archive.exec('CREATE TABLE articles (id INTEGER PRIMARY KEY, pub_date_effective TEXT, pub_date TEXT, ingested_at TEXT)');
archive.prepare("INSERT INTO articles VALUES (7, '2020-01-01T00:00:00Z', NULL, '2020-01-01T00:00:00Z')").run();
const intelligence = new Database(':memory:');
initAutonomySchema(intelligence);
intelligence.prepare("INSERT INTO autonomy_instruments(symbol, broker, active, tradable) VALUES ('NVDA','test',1,1)").run();
const pred = (instrument) => ({
instrument, direction: 'positive', event_type: 'earnings',
horizon_days: 10, evidence_article_ids: [7],
});
// EURUSD used to take NVDA down with it and lose the whole proposal
const result = acceptProposal(intelligence, archive,
{ predictions: [pred('NVDA'), pred('EURUSD')] },
{ informationCutoff: '2026-01-01T00:00:00Z', strategyVersion: 'test' });
assert.equal(result.predictionCount, 1);
assert.deepEqual(result.droppedInstruments, ['EURUSD']);
const stored = intelligence.prepare('SELECT instrument FROM autonomy_predictions').all();
assert.deepEqual(stored.map((r) => r.instrument), ['NVDA']);
});
test('lookahead still rejects the whole proposal, not just one prediction', () => {
const archive = new Database(':memory:');
archive.exec('CREATE TABLE articles (id INTEGER PRIMARY KEY, pub_date_effective TEXT, pub_date TEXT, ingested_at TEXT)');
archive.prepare("INSERT INTO articles VALUES (7, '2020-06-01T00:00:00Z', NULL, '2020-06-01T00:00:00Z')").run();
const intelligence = new Database(':memory:');
initAutonomySchema(intelligence);
intelligence.prepare("INSERT INTO autonomy_instruments(symbol, broker, active, tradable) VALUES ('NVDA','test',1,1)").run();
// evidence postdates the cutoff: corrupt, not merely untradable
assert.throws(() => acceptProposal(intelligence, archive, {
predictions: [{ instrument: 'NVDA', direction: 'positive', event_type: 'earnings', horizon_days: 10, evidence_article_ids: [7] }],
}, { informationCutoff: '2020-01-01T00:00:00Z' }), /missing evidence/);
assert.equal(intelligence.prepare('SELECT COUNT(*) AS n FROM autonomy_predictions').get().n, 0);
});