diff --git a/src/autonomy/coordinator.js b/src/autonomy/coordinator.js index c6afde0..d2a5458 100644 --- a/src/autonomy/coordinator.js +++ b/src/autonomy/coordinator.js @@ -96,15 +96,37 @@ function verifyEvidence(archiveDb, articleIds, informationCutoff = null) { function acceptProposal(intelligenceDb, archiveDb, 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)) { 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(` INSERT INTO autonomy_proposals (event_id, payload, information_cutoff, coordinator_model, prompt_version, status) @@ -117,18 +139,18 @@ function acceptProposal(intelligenceDb, archiveDb, raw, metadata = {}) { VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `); const tx = intelligenceDb.transaction(() => { - const proposalResult = insert.run(metadata.eventId || null, JSON.stringify(proposal), proposal.informationCutoff, - proposal.coordinatorModel, proposal.promptVersion); - for (const prediction of proposal.predictions) { + const proposalResult = insert.run(metadata.eventId || null, JSON.stringify(stored), stored.informationCutoff, + stored.coordinatorModel, stored.promptVersion); + for (const prediction of tradable) { insertPrediction.run(proposalResult.lastInsertRowid, metadata.eventId || null, prediction.instrument, 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.origin || 'live', metadata.replayRunId || null); } 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') { diff --git a/test/autonomy.test.js b/test/autonomy.test.js index 93e2806..1817c10 100644 --- a/test/autonomy.test.js +++ b/test/autonomy.test.js @@ -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('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); +});